This commit is contained in:
beabigegg
2025-09-12 08:56:44 +08:00
commit 0bc8c4c81c
86 changed files with 23146 additions and 0 deletions

View File

@@ -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": []
}
}

38
.dockerignore Normal file
View File

@@ -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

51
.env Normal file
View File

@@ -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

51
.env.example Normal file
View File

@@ -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

150
.gitignore vendored Normal file
View File

@@ -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

384
DEPLOYMENT.md Normal file
View File

@@ -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*

86
Dockerfile Normal file
View File

@@ -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"]

334
README.md Normal file
View File

@@ -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 所有,僅供公司內部使用。

316
USERMANUAL.md Normal file
View File

@@ -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*

2
api.txt Normal file
View File

@@ -0,0 +1,2 @@
base_url:https://dify.theaken.com/v1
api:app-SmB3TwVMcp5OyQviYeAoTden

159
app.py Normal file
View File

@@ -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('/<path:path>')
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)

218
app/__init__.py Normal file
View File

@@ -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)}")
# 導入模型在需要時才進行,避免循環導入

25
app/api/__init__.py Normal file
View File

@@ -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)

1071
app/api/admin.py Normal file

File diff suppressed because it is too large Load Diff

325
app/api/auth.py Normal file
View File

@@ -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

691
app/api/files.py Normal file
View File

@@ -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('/<job_uuid>/download/<language_code>', 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('/<job_uuid>/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('/<job_uuid>/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('/<job_uuid>/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

224
app/api/health.py Normal file
View File

@@ -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'
})

548
app/api/jobs.py Normal file
View File

@@ -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('/<job_uuid>', 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('/<job_uuid>/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('/<job_uuid>/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('/<job_uuid>', 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

331
app/api/notification.py Normal file
View File

@@ -0,0 +1,331 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
通知系統 API 路由
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from flask import Blueprint, jsonify, request, g
from 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('/<notification_id>', 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('/<notification_id>/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('/<notification_id>', 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

157
app/config.py Normal file
View File

@@ -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
}

27
app/models/__init__.py Normal file
View File

@@ -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'
]

138
app/models/cache.py Normal file
View File

@@ -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'<TranslationCache {self.source_text_hash[:8]}...>'
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

296
app/models/job.py Normal file
View File

@@ -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'<TranslationJob {self.job_uuid}>'
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'<JobFile {self.filename}>'
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())

211
app/models/log.py Normal file
View File

@@ -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'<SystemLog {self.level} {self.module}>'
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]]
}

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
通知系統資料模型
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from datetime import datetime
from enum import Enum
from sqlalchemy import func
from sqlalchemy.orm import relationship
from app import db
import uuid
import json
class NotificationType(str, Enum):
"""通知類型枚舉"""
SUCCESS = "success" # 成功
ERROR = "error" # 錯誤
WARNING = "warning" # 警告
INFO = "info" # 資訊
SYSTEM = "system" # 系統
class Notification(db.Model):
"""通知模型"""
__tablename__ = 'dt_notifications'
# 主鍵
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
notification_uuid = db.Column(db.String(36), unique=True, nullable=False, index=True,
default=lambda: str(uuid.uuid4()), comment='通知唯一識別碼')
# 基本資訊
user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), nullable=False, comment='使用者ID')
type = db.Column(db.String(20), nullable=False, default=NotificationType.INFO.value, comment='通知類型')
title = db.Column(db.String(255), nullable=False, comment='通知標題')
message = db.Column(db.Text, nullable=False, comment='通知內容')
# 關聯資訊(可選)
job_uuid = db.Column(db.String(36), nullable=True, comment='關聯任務UUID')
link = db.Column(db.String(500), nullable=True, comment='相關連結')
# 狀態
is_read = db.Column(db.Boolean, default=False, nullable=False, comment='是否已讀')
read_at = db.Column(db.DateTime, nullable=True, comment='閱讀時間')
# 時間戳記
created_at = db.Column(db.DateTime, default=func.now(), nullable=False, comment='建立時間')
expires_at = db.Column(db.DateTime, nullable=True, comment='過期時間')
# 額外數據JSON 格式儲存)
extra_data = db.Column(db.JSON, nullable=True, comment='額外數據')
# 關聯
user = db.relationship("User", backref="notifications")
def __repr__(self):
return f"<Notification {self.notification_uuid}: {self.title}>"
def to_dict(self):
"""轉換為字典格式"""
return {
'id': self.notification_uuid, # 前端使用 UUID
'user_id': self.user_id,
'type': self.type,
'title': self.title,
'message': self.message,
'job_uuid': self.job_uuid,
'link': self.link,
'is_read': self.is_read,
'read': self.is_read, # 為了前端相容
'read_at': self.read_at.isoformat() if self.read_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'extra_data': self.extra_data
}
def mark_as_read(self):
"""標記為已讀"""
self.is_read = True
self.read_at = datetime.now()
@classmethod
def create_job_notification(cls, user_id, job_uuid, title, message, notification_type=NotificationType.INFO):
"""創建任務相關通知"""
return cls(
user_id=user_id,
job_uuid=job_uuid,
type=notification_type.value,
title=title,
message=message,
link=f"/job/{job_uuid}" # 連結到任務詳情頁
)

233
app/models/stats.py Normal file
View File

@@ -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'<APIUsageStats {self.api_endpoint}>'
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
]

118
app/models/user.py Normal file
View File

@@ -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'<User {self.username}>'
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()

19
app/services/__init__.py Normal file
View File

@@ -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'
]

View File

@@ -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

302
app/services/dify_client.py Normal file
View File

@@ -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 ''}")

View File

@@ -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 <w:p> 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)}")

View File

@@ -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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #2563eb; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f8fafc; padding: 30px; border: 1px solid #e5e7eb; }}
.info-box {{ background-color: #dbeafe; border-left: 4px solid #2563eb; padding: 15px; margin: 20px 0; }}
.footer {{ background-color: #374151; color: #d1d5db; padding: 15px; text-align: center; font-size: 12px; border-radius: 0 0 8px 8px; }}
.success {{ color: #059669; font-weight: bold; }}
.download-section {{ margin: 20px 0; }}
.download-link {{ display: inline-block; background-color: #2563eb; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin: 5px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎉 翻譯任務完成</h1>
</div>
<div class="content">
<p>親愛的 <strong>{job.user.display_name}</strong></p>
<p class="success">您的文件翻譯任務已成功完成!</p>
<div class="info-box">
<h3>📋 任務詳細資訊</h3>
<p><strong>檔案名稱:</strong> {job.original_filename}</p>
<p><strong>任務編號:</strong> {job.job_uuid}</p>
<p><strong>來源語言:</strong> {job.source_language}</p>
<p><strong>目標語言:</strong> {', '.join(job.target_languages)}</p>
<p><strong>處理時間:</strong> {processing_time}</p>
<p><strong>完成時間:</strong> {job.completed_at.strftime('%Y-%m-%d %H:%M:%S') if job.completed_at else '未知'}</p>
{f'<p><strong>總成本:</strong> ${job.total_cost:.4f}</p>' if job.total_cost else ''}
</div>
<div class="download-section">
<h3>📥 下載翻譯檔案</h3>
<p>請登入系統下載您的翻譯檔案:</p>
<p>{'<br>'.join(download_links)}</p>
<p style="margin-top: 15px;">
<strong>注意:</strong> 翻譯檔案將在系統中保留 7 天,請及時下載。
</p>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p>感謝您使用 {self.app_name}</p>
<p>如有任何問題,請聯繫系統管理員。</p>
</div>
</div>
<div class="footer">
<p>此郵件由 {self.app_name} 系統自動發送,請勿回覆。</p>
<p>發送時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
</div>
</body>
</html>
"""
# 純文字版本
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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #dc2626; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f8fafc; padding: 30px; border: 1px solid #e5e7eb; }}
.error-box {{ background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 15px; margin: 20px 0; }}
.footer {{ background-color: #374151; color: #d1d5db; padding: 15px; text-align: center; font-size: 12px; border-radius: 0 0 8px 8px; }}
.error {{ color: #dc2626; font-weight: bold; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>❌ 翻譯任務失敗</h1>
</div>
<div class="content">
<p>親愛的 <strong>{job.user.display_name}</strong></p>
<p class="error">很抱歉,您的文件翻譯任務處理失敗。</p>
<div class="error-box">
<h3>📋 任務資訊</h3>
<p><strong>檔案名稱:</strong> {job.original_filename}</p>
<p><strong>任務編號:</strong> {job.job_uuid}</p>
<p><strong>重試次數:</strong> {job.retry_count}</p>
<p><strong>錯誤訊息:</strong> {job.error_message or '未知錯誤'}</p>
<p><strong>失敗時間:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div style="margin-top: 20px;">
<p><strong>建議處理方式:</strong></p>
<ul>
<li>檢查檔案格式是否正確</li>
<li>確認檔案沒有損壞</li>
<li>稍後再次嘗試上傳</li>
<li>如問題持續,請聯繫系統管理員</li>
</ul>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p>如需協助,請聯繫系統管理員。</p>
</div>
</div>
<div class="footer">
<p>此郵件由 {self.app_name} 系統自動發送,請勿回覆。</p>
<p>發送時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
</div>
</body>
</html>
"""
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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #f59e0b; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f8fafc; padding: 30px; border: 1px solid #e5e7eb; }}
.footer {{ background-color: #374151; color: #d1d5db; padding: 15px; text-align: center; font-size: 12px; border-radius: 0 0 8px 8px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔔 系統管理通知</h1>
</div>
<div class="content">
<p>系統管理員您好,</p>
<div style="background-color: #fef3c7; border-left: 4px solid #f59e0b; padding: 15px; margin: 20px 0;">
<h3>{subject}</h3>
<p>{message}</p>
</div>
<p>發送時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div class="footer">
<p>此郵件由 {self.app_name} 系統自動發送,請勿回覆。</p>
</div>
</div>
</body>
</html>
"""
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

File diff suppressed because it is too large Load Diff

16
app/tasks/__init__.py Normal file
View File

@@ -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'
]

350
app/tasks/translation.py Normal file
View File

@@ -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'
)

34
app/utils/__init__.py Normal file
View File

@@ -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'
]

238
app/utils/decorators.py Normal file
View File

@@ -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

52
app/utils/exceptions.py Normal file
View File

@@ -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

280
app/utils/helpers.py Normal file
View File

@@ -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

232
app/utils/ldap_auth.py Normal file
View File

@@ -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

126
app/utils/logger.py Normal file
View File

@@ -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)

84
app/utils/response.py Normal file
View File

@@ -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

104
app/utils/timezone.py Normal file
View File

@@ -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 以保持兼容性

203
app/utils/validators.py Normal file
View File

@@ -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

230
app/websocket.py Normal file
View File

@@ -0,0 +1,230 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WebSocket 服務模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect
from flask_jwt_extended import decode_token, get_jwt
from flask import request
from functools import wraps
import logging
# 初始化 SocketIO
socketio = SocketIO(
cors_allowed_origins="*",
async_mode='threading',
logger=True,
engineio_logger=False
)
# 存儲用戶連接
connected_users = {}
logger = logging.getLogger(__name__)
def jwt_required_ws(f):
"""WebSocket JWT 驗證裝飾器"""
@wraps(f)
def decorated_function(*args, **kwargs):
try:
# 從查詢參數獲取 token
token = request.args.get('token')
if not token:
disconnect()
return False
# 解碼 token
decoded = decode_token(token)
user_id = decoded.get('sub')
# 儲存用戶信息
request.user_id = user_id
return f(*args, **kwargs)
except Exception as e:
logger.error(f"WebSocket authentication failed: {e}")
disconnect()
return False
return decorated_function
@socketio.on('connect')
def handle_connect(auth):
"""處理客戶端連接"""
try:
# 從認證數據獲取 token
if auth and 'token' in auth:
token = auth['token']
decoded = decode_token(token)
user_id = decoded.get('sub')
# 記錄連接
connected_users[request.sid] = {
'user_id': user_id,
'sid': request.sid
}
# 加入用戶專屬房間
join_room(f"user_{user_id}")
logger.info(f"User {user_id} connected with session {request.sid}")
# 發送連接成功消息
emit('connected', {
'message': '連接成功',
'user_id': user_id
})
return True
else:
logger.warning("Connection attempt without authentication")
disconnect()
return False
except Exception as e:
logger.error(f"Connection error: {e}")
disconnect()
return False
@socketio.on('disconnect')
def handle_disconnect():
"""處理客戶端斷開連接"""
try:
if request.sid in connected_users:
user_info = connected_users[request.sid]
user_id = user_info['user_id']
# 離開房間
leave_room(f"user_{user_id}")
# 移除連接記錄
del connected_users[request.sid]
logger.info(f"User {user_id} disconnected")
except Exception as e:
logger.error(f"Disconnect error: {e}")
@socketio.on('ping')
def handle_ping():
"""處理心跳包"""
emit('pong', {'timestamp': request.args.get('timestamp')})
@socketio.on('subscribe_job')
def handle_subscribe_job(data):
"""訂閱任務更新"""
try:
job_uuid = data.get('job_uuid')
if job_uuid:
join_room(f"job_{job_uuid}")
logger.info(f"Client {request.sid} subscribed to job {job_uuid}")
emit('subscribed', {'job_uuid': job_uuid})
except Exception as e:
logger.error(f"Subscribe job error: {e}")
@socketio.on('unsubscribe_job')
def handle_unsubscribe_job(data):
"""取消訂閱任務更新"""
try:
job_uuid = data.get('job_uuid')
if job_uuid:
leave_room(f"job_{job_uuid}")
logger.info(f"Client {request.sid} unsubscribed from job {job_uuid}")
emit('unsubscribed', {'job_uuid': job_uuid})
except Exception as e:
logger.error(f"Unsubscribe job error: {e}")
# 工具函數:發送通知
def send_notification_to_user(user_id, notification_data):
"""
向特定用戶發送通知
Args:
user_id: 用戶ID
notification_data: 通知數據
"""
try:
socketio.emit(
'new_notification',
notification_data,
room=f"user_{user_id}",
namespace='/'
)
logger.info(f"Notification sent to user {user_id}")
except Exception as e:
logger.error(f"Failed to send notification: {e}")
def send_job_update(job_uuid, update_data):
"""
發送任務更新
Args:
job_uuid: 任務UUID
update_data: 更新數據
"""
try:
socketio.emit(
'job_update',
{
'job_uuid': job_uuid,
**update_data
},
room=f"job_{job_uuid}",
namespace='/'
)
logger.info(f"Job update sent for {job_uuid}")
except Exception as e:
logger.error(f"Failed to send job update: {e}")
def broadcast_system_message(message, message_type='info'):
"""
廣播系統消息給所有連接的用戶
Args:
message: 消息內容
message_type: 消息類型
"""
try:
socketio.emit(
'system_message',
{
'message': message,
'type': message_type
},
namespace='/',
broadcast=True
)
logger.info(f"System message broadcasted: {message}")
except Exception as e:
logger.error(f"Failed to broadcast system message: {e}")
# 初始化函數
def init_websocket(app):
"""
初始化 WebSocket
Args:
app: Flask 應用實例
"""
socketio.init_app(app)
logger.info("WebSocket initialized")
return socketio

34
celery_app.py Normal file
View File

@@ -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())}")

85
docker-compose.yml Normal file
View File

@@ -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

18
frontend/.env.example Normal file
View File

@@ -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

58
frontend/.eslintrc.cjs Normal file
View File

@@ -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'
}
}

14
frontend/.prettierrc Normal file
View File

@@ -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
}

89
frontend/auto-imports.d.ts vendored Normal file
View File

@@ -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')
}

46
frontend/index.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/panjit-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PANJIT Document Translator</title>
<meta name="description" content="PANJIT Document Translator Web System - 企業級文件批量翻譯管理系統" />
<meta name="keywords" content="文件翻譯,批量翻譯,PANJIT,企業級翻譯系統" />
<style>
/* 載入頁面樣式 */
#loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="app">
<div id="loading">
<div class="loading-spinner"></div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

44
frontend/package.json Normal file
View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

95
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,95 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
onMounted(async () => {
// 應用啟動時檢查用戶是否已登入
await authStore.checkAuth()
})
</script>
<style lang="scss">
#app {
width: 100%;
height: 100vh;
margin: 0;
padding: 0;
}
// 全局樣式重置
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--el-bg-color-page);
color: var(--el-text-color-primary);
}
// 自定義滾動條樣式
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--el-border-color-light);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color);
}
// Element Plus 主題色彩自定義 - 深色系
:root {
--el-color-primary: #2c3e50;
--el-color-primary-light-3: #5d6d7e;
--el-color-primary-light-5: #85929e;
--el-color-primary-light-7: #aeb6bf;
--el-color-primary-light-8: #d5d8dc;
--el-color-primary-light-9: #eaeded;
--el-color-primary-dark-2: #1a252f;
}
// 過渡動畫
.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 ease;
}
.slide-enter-from {
transform: translateX(-20px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(20px);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,407 @@
<template>
<div class="app-layout">
<!-- 側邊欄 -->
<aside class="layout-sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-show': mobileSidebarVisible }">
<div class="sidebar-header">
<router-link to="/home" class="logo">
<div class="logo-icon">
<img src="/panjit-logo.png" alt="PANJIT Logo" style="width: 32px; height: 32px;" />
</div>
<div class="logo-text">PANJIT 翻譯系統</div>
</router-link>
</div>
<nav class="sidebar-menu">
<router-link
v-for="route in menuRoutes"
:key="route.name"
:to="route.path"
class="menu-item"
:class="{ active: isActiveRoute(route.path) }"
@click="handleMenuClick"
>
<el-icon class="menu-icon">
<component :is="route.meta.icon" />
</el-icon>
<span class="menu-text">{{ route.meta.title }}</span>
</router-link>
</nav>
<div class="sidebar-footer" v-if="!sidebarCollapsed">
<button class="collapse-toggle" @click="toggleSidebar">
<el-icon><Fold /></el-icon>
收合側邊欄
</button>
</div>
<div class="sidebar-footer" v-else>
<button class="collapse-toggle" @click="toggleSidebar">
<el-icon><Expand /></el-icon>
</button>
</div>
</aside>
<!-- 移動設備遮罩 -->
<div class="mobile-mask" :class="{ show: mobileSidebarVisible }" @click="closeMobileSidebar"></div>
<!-- 主要內容區 -->
<main class="layout-main">
<!-- 頂部導航欄 -->
<header class="layout-header">
<div class="header-left">
<button class="menu-toggle" @click="toggleMobileSidebar">
<el-icon><Menu /></el-icon>
</button>
<nav class="breadcrumb">
<span class="breadcrumb-item">{{ currentRoute.meta?.title || '首頁' }}</span>
</nav>
</div>
<div class="header-right">
<!-- 通知鈴鐺 -->
<div class="notification-bell" @click="showNotifications">
<el-icon><Bell /></el-icon>
<div class="badge" v-if="unreadCount > 0"></div>
</div>
<!-- 用戶下拉選單 -->
<el-dropdown class="user-avatar" @command="handleUserMenuCommand">
<div class="avatar-button">
<div class="avatar">
{{ userInitials }}
</div>
<div class="user-info">
<div class="user-name">{{ authStore.userName }}</div>
<div class="user-role">{{ authStore.isAdmin ? '管理員' : '使用者' }}</div>
</div>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
個人設定
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>
登出
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 內容區域 -->
<div class="layout-content">
<div class="content-wrapper">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</div>
</main>
<!-- 通知抽屜 -->
<el-drawer
v-model="notificationDrawerVisible"
title="系統通知"
direction="rtl"
size="400px"
>
<div class="notification-list">
<div v-if="notifications.length === 0" class="empty-state">
<el-icon class="empty-icon"><Bell /></el-icon>
<div class="empty-title">暫無通知</div>
<div class="empty-description">您目前沒有未讀通知</div>
</div>
<div v-else>
<div
v-for="notification in notifications"
:key="notification.id"
class="notification-item"
:class="{ unread: !notification.read }"
>
<div class="notification-icon" :class="notification.type">
<el-icon>
<component :is="getNotificationIcon(notification.type)" />
</el-icon>
</div>
<div class="notification-content">
<div class="notification-title">{{ notification.title }}</div>
<div class="notification-message">{{ notification.message }}</div>
<div class="notification-time">{{ formatTime(notification.created_at) }}</div>
</div>
<div class="notification-actions">
<el-button
v-if="!notification.read"
type="text"
size="small"
@click="markAsRead(notification.id)"
>
標記已讀
</el-button>
</div>
</div>
</div>
</div>
<template #footer>
<div class="notification-footer">
<el-button @click="markAllAsRead" v-if="unreadCount > 0">
全部標記已讀
</el-button>
<el-button type="primary" @click="notificationDrawerVisible = false">
關閉
</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notification'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Document, House, Upload, List, Clock, User, Setting, Bell, Menu,
Fold, Expand, SwitchButton, SuccessFilled, WarningFilled,
CircleCloseFilled, InfoFilled, Refresh
} from '@element-plus/icons-vue'
import { initWebSocket, cleanupWebSocket } from '@/utils/websocket'
// Store 和 Router
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
// 響應式數據
const sidebarCollapsed = ref(false)
const mobileSidebarVisible = ref(false)
const notificationDrawerVisible = ref(false)
// 從 store 獲取通知相關數據
const notifications = computed(() => notificationStore.notifications)
const unreadCount = computed(() => notificationStore.unreadCount)
// 計算屬性
const currentRoute = computed(() => route)
const userInitials = computed(() => {
const name = authStore.userName || authStore.user?.username || 'U'
return name.charAt(0).toUpperCase()
})
const menuRoutes = computed(() => {
const routes = [
{ name: 'Home', path: '/home', meta: { title: '首頁', icon: 'House', showInMenu: true }},
{ name: 'Upload', path: '/upload', meta: { title: '檔案上傳', icon: 'Upload', showInMenu: true }},
{ name: 'Jobs', path: '/jobs', meta: { title: '任務列表', icon: 'List', showInMenu: true }},
{ name: 'History', path: '/history', meta: { title: '歷史記錄', icon: 'Clock', showInMenu: true }}
]
// 如果是管理員,顯示管理後台選項
if (authStore.isAdmin) {
routes.push({
name: 'Admin',
path: '/admin',
meta: { title: '管理後台', icon: 'Setting', showInMenu: true, requiresAdmin: true }
})
}
return routes.filter(route => route.meta.showInMenu)
})
// 方法
const isActiveRoute = (path) => {
return route.path === path || route.path.startsWith(path + '/')
}
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
localStorage.setItem('sidebarCollapsed', sidebarCollapsed.value.toString())
}
const toggleMobileSidebar = () => {
mobileSidebarVisible.value = !mobileSidebarVisible.value
}
const closeMobileSidebar = () => {
mobileSidebarVisible.value = false
}
const handleMenuClick = () => {
if (window.innerWidth <= 768) {
closeMobileSidebar()
}
}
const showNotifications = async () => {
notificationDrawerVisible.value = true
// 載入最新通知
await notificationStore.fetchNotifications()
}
const handleUserMenuCommand = async (command) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'logout':
try {
await ElMessageBox.confirm('確定要登出嗎?', '確認登出', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
await authStore.logout()
cleanupWebSocket()
router.push('/login')
} catch (error) {
if (error !== 'cancel') {
console.error('登出錯誤:', error)
}
}
break
}
}
const markAsRead = async (notificationId) => {
try {
await notificationStore.markAsRead(notificationId)
} catch (error) {
console.error('標記已讀失敗:', error)
}
}
const markAllAsRead = async () => {
try {
await notificationStore.markAllAsRead()
} catch (error) {
console.error('標記全部已讀失敗:', error)
}
}
const getNotificationIcon = (type) => {
const iconMap = {
success: 'SuccessFilled',
warning: 'WarningFilled',
error: 'CircleCloseFilled',
info: 'InfoFilled'
}
return iconMap[type] || 'InfoFilled'
}
const formatTime = (timestamp) => {
const now = new Date()
const time = new Date(timestamp)
const diff = now - time
if (diff < 60000) return '剛剛'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分鐘前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小時前`
return time.toLocaleDateString('zh-TW')
}
// 響應式處理
const handleResize = () => {
if (window.innerWidth > 768) {
mobileSidebarVisible.value = false
}
}
// 生命周期
onMounted(() => {
// 恢復側邊欄狀態
const savedCollapsed = localStorage.getItem('sidebarCollapsed')
if (savedCollapsed !== null) {
sidebarCollapsed.value = savedCollapsed === 'true'
}
// 暫時禁用 WebSocket 連接
// initWebSocket()
// 載入通知
notificationStore.fetchNotifications()
// 監聽窗口大小變化
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<style lang="scss" scoped>
// 通知相關樣式
.notification-list {
.notification-item {
display: flex;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&.unread {
background-color: var(--el-color-primary-light-9);
border-left: 3px solid var(--el-color-primary);
padding-left: 9px;
}
.notification-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
&.success { background-color: var(--el-color-success-light-9); color: var(--el-color-success); }
&.warning { background-color: var(--el-color-warning-light-9); color: var(--el-color-warning); }
&.error { background-color: var(--el-color-danger-light-9); color: var(--el-color-danger); }
&.info { background-color: var(--el-color-info-light-9); color: var(--el-color-info); }
}
.notification-content {
flex: 1;
.notification-title {
font-weight: 600;
margin-bottom: 4px;
color: var(--el-text-color-primary);
}
.notification-message {
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.4;
margin-bottom: 4px;
}
.notification-time {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.notification-actions {
margin-left: 12px;
}
}
}
.notification-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

49
frontend/src/main.js Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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}`)
}
}

View File

@@ -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')
}
}

View File

@@ -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`)
}
}

View File

@@ -0,0 +1,63 @@
import { request } from '@/utils/request'
/**
* 通知相關 API 服務
*/
export const notificationAPI = {
/**
* 獲取通知列表
* @param {Object} params - 查詢參數
* @param {number} params.page - 頁碼
* @param {number} params.per_page - 每頁數量
* @param {string} params.status - 狀態過濾 ('all', 'unread', 'read')
* @param {string} params.type - 類型過濾
*/
getNotifications(params = {}) {
return request.get('/notifications', { params })
},
/**
* 獲取單個通知詳情
* @param {string} notificationId - 通知ID
*/
getNotification(notificationId) {
return request.get(`/notifications/${notificationId}`)
},
/**
* 標記通知為已讀
* @param {string} notificationId - 通知ID
*/
markAsRead(notificationId) {
return request.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')
}
}

View File

@@ -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
}
}
})

193
frontend/src/stores/auth.js Normal file
View File

@@ -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'] // 只持久化這些欄位
}
})

411
frontend/src/stores/jobs.js Normal file
View File

@@ -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
}
}
}
})

View File

@@ -0,0 +1,310 @@
import { defineStore } from 'pinia'
import { notificationAPI } from '@/services/notification'
import { ElMessage } from 'element-plus'
export const useNotificationStore = defineStore('notification', {
state: () => ({
notifications: [],
unreadCount: 0,
loading: false,
pagination: {
total: 0,
page: 1,
per_page: 20,
pages: 0
}
}),
getters: {
unreadNotifications: (state) => {
return state.notifications.filter(notification => !notification.is_read)
},
readNotifications: (state) => {
return state.notifications.filter(notification => notification.is_read)
},
hasUnreadNotifications: (state) => {
return state.unreadCount > 0
}
},
actions: {
/**
* 獲取通知列表
* @param {Object} params - 查詢參數
*/
async fetchNotifications(params = {}) {
try {
this.loading = true
const response = await notificationAPI.getNotifications({
page: this.pagination.page,
per_page: this.pagination.per_page,
...params
})
if (response.success) {
this.notifications = response.data.notifications
this.unreadCount = response.data.unread_count
this.pagination = {
...this.pagination,
...response.data.pagination
}
console.log('📮 [Notification] 通知列表已更新', {
total: this.pagination.total,
unread: this.unreadCount
})
}
return response
} catch (error) {
console.error('❌ [Notification] 獲取通知列表失敗:', error)
ElMessage.error('獲取通知失敗')
throw error
} finally {
this.loading = false
}
},
/**
* 標記通知為已讀
* @param {string} notificationId - 通知ID
*/
async markAsRead(notificationId) {
try {
const response = await notificationAPI.markAsRead(notificationId)
if (response.success) {
// 更新本地狀態
const notification = this.notifications.find(n => n.id === notificationId)
if (notification && !notification.is_read) {
notification.is_read = true
notification.read = true
notification.read_at = new Date().toISOString()
this.unreadCount = Math.max(0, this.unreadCount - 1)
}
console.log('✅ [Notification] 通知已標記為已讀:', notificationId)
}
return response
} catch (error) {
console.error('❌ [Notification] 標記已讀失敗:', error)
ElMessage.error('標記已讀失敗')
throw error
}
},
/**
* 標記所有通知為已讀
*/
async markAllAsRead() {
try {
const response = await notificationAPI.markAllAsRead()
if (response.success) {
// 更新本地狀態
this.notifications.forEach(notification => {
if (!notification.is_read) {
notification.is_read = true
notification.read = true
notification.read_at = new Date().toISOString()
}
})
this.unreadCount = 0
console.log('✅ [Notification] 所有通知已標記為已讀')
ElMessage.success(response.message || '所有通知已標記為已讀')
}
return response
} catch (error) {
console.error('❌ [Notification] 標記全部已讀失敗:', error)
ElMessage.error('標記全部已讀失敗')
throw error
}
},
/**
* 刪除通知
* @param {string} notificationId - 通知ID
*/
async deleteNotification(notificationId) {
try {
const response = await notificationAPI.deleteNotification(notificationId)
if (response.success) {
// 從本地狀態移除
const index = this.notifications.findIndex(n => n.id === notificationId)
if (index !== -1) {
const notification = this.notifications[index]
if (!notification.is_read) {
this.unreadCount = Math.max(0, this.unreadCount - 1)
}
this.notifications.splice(index, 1)
this.pagination.total = Math.max(0, this.pagination.total - 1)
}
console.log('🗑️ [Notification] 通知已刪除:', notificationId)
}
return response
} catch (error) {
console.error('❌ [Notification] 刪除通知失敗:', error)
ElMessage.error('刪除通知失敗')
throw error
}
},
/**
* 清空所有已讀通知
*/
async clearNotifications() {
try {
const response = await notificationAPI.clearNotifications()
if (response.success) {
// 從本地狀態移除已讀通知
this.notifications = this.notifications.filter(n => !n.is_read)
this.pagination.total = this.notifications.length
console.log('🧹 [Notification] 已讀通知已清除')
ElMessage.success(response.message || '已讀通知已清除')
}
return response
} catch (error) {
console.error('❌ [Notification] 清除通知失敗:', error)
ElMessage.error('清除通知失敗')
throw error
}
},
/**
* 添加新通知(用於 WebSocket 推送)
* @param {Object} notification - 通知數據
*/
addNotification(notification) {
// 檢查是否已存在
const exists = this.notifications.find(n => n.id === notification.id)
if (!exists) {
// 添加到列表開頭
this.notifications.unshift(notification)
// 更新未讀數量
if (!notification.is_read) {
this.unreadCount += 1
}
// 更新總數
this.pagination.total += 1
console.log('📩 [Notification] 新通知已添加:', notification.title)
// 顯示通知
ElMessage({
type: this.getMessageType(notification.type),
title: notification.title,
message: notification.message,
duration: 5000
})
}
},
/**
* 更新通知
* @param {Object} notification - 通知數據
*/
updateNotification(notification) {
const index = this.notifications.findIndex(n => n.id === notification.id)
if (index !== -1) {
const oldNotification = this.notifications[index]
// 更新未讀數量
if (oldNotification.is_read !== notification.is_read) {
if (notification.is_read) {
this.unreadCount = Math.max(0, this.unreadCount - 1)
} else {
this.unreadCount += 1
}
}
// 更新通知
this.notifications[index] = { ...oldNotification, ...notification }
console.log('📝 [Notification] 通知已更新:', notification.id)
}
},
/**
* 創建測試通知(開發用)
*/
async createTestNotification() {
try {
const response = await notificationAPI.createTestNotification()
if (response.success) {
// 重新獲取通知列表
await this.fetchNotifications()
ElMessage.success('測試通知已創建')
}
return response
} catch (error) {
console.error('❌ [Notification] 創建測試通知失敗:', error)
ElMessage.error('創建測試通知失敗')
throw error
}
},
/**
* 設置分頁
* @param {number} page - 頁碼
* @param {number} per_page - 每頁數量
*/
setPagination(page, per_page) {
this.pagination.page = page
if (per_page) {
this.pagination.per_page = per_page
}
},
/**
* 重置狀態
*/
reset() {
this.notifications = []
this.unreadCount = 0
this.loading = false
this.pagination = {
total: 0,
page: 1,
per_page: 20,
pages: 0
}
},
/**
* 獲取 ElMessage 類型
* @param {string} type - 通知類型
*/
getMessageType(type) {
const typeMap = {
'success': 'success',
'error': 'error',
'warning': 'warning',
'info': 'info',
'system': 'info'
}
return typeMap[type] || 'info'
}
}
})

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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); }
}

View File

@@ -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
);

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,538 @@
<template>
<div class="admin-jobs-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">全部任務管理</h1>
<div class="page-actions">
<el-button @click="refreshJobs" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- 篩選條件 -->
<div class="filter-section">
<div class="content-card">
<div class="card-body">
<div class="filter-row">
<div class="filter-item">
<label>用戶</label>
<el-select v-model="filters.user_id" @change="handleFilterChange" clearable placeholder="選擇用戶">
<el-option label="全部用戶" value="all" />
<el-option
v-for="user in users"
:key="user.id"
:label="user.display_name || user.username"
:value="user.id"
/>
</el-select>
</div>
<div class="filter-item">
<label>狀態</label>
<el-select v-model="filters.status" @change="handleFilterChange" clearable placeholder="選擇狀態">
<el-option label="全部狀態" value="all" />
<el-option label="等待中" value="PENDING" />
<el-option label="處理中" value="PROCESSING" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="失敗" value="FAILED" />
<el-option label="重試" value="RETRY" />
</el-select>
</div>
<div class="filter-item">
<label>檔案名搜尋</label>
<el-input
v-model="filters.search"
@change="handleFilterChange"
placeholder="輸入檔案名"
clearable
/>
</div>
</div>
</div>
</div>
</div>
<!-- 任務列表 -->
<div class="jobs-section">
<div class="content-card">
<div class="card-header">
<h3 class="card-title">任務列表</h3>
<div class="card-info">
{{ pagination.total }} 個任務
</div>
</div>
<div class="card-body">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="jobs.length === 0" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<div class="empty-title">暫無任務記錄</div>
</div>
<div v-else class="jobs-table">
<el-table :data="jobs" style="width: 100%">
<el-table-column prop="original_filename" label="檔案名稱" min-width="200">
<template #default="{ row }">
<div class="file-info">
<div class="file-icon" :class="getFileExtension(row.original_filename)">
{{ getFileExtension(row.original_filename).toUpperCase() }}
</div>
<span class="file-name">{{ row.original_filename }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="用戶" width="120">
<template #default="{ row }">
{{ row.user?.display_name || row.user?.username || '未知用戶' }}
</template>
</el-table-column>
<el-table-column prop="target_languages" label="目標語言" width="150">
<template #default="{ row }">
<div class="language-tags">
<el-tag
v-for="lang in row.target_languages"
:key="lang"
size="small"
type="primary"
>
{{ getLanguageText(lang) }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="狀態" width="100">
<template #default="{ row }">
<el-tag
:type="getStatusTagType(row.status)"
size="small"
>
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_cost" label="成本" width="80">
<template #default="{ row }">
${{ (row.total_cost || 0).toFixed(4) }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="建立時間" width="120">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button
type="text"
size="small"
@click="viewJobDetail(row.job_uuid)"
>
查看
</el-button>
<el-button
v-if="row.status === 'PENDING' || row.status === 'PROCESSING'"
type="text"
size="small"
@click="cancelJob(row.job_uuid)"
>
取消
</el-button>
<el-button
type="text"
size="small"
@click="deleteJob(row.job_uuid)"
>
刪除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分頁 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:page-sizes="[20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { adminAPI } from '@/services/admin'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Refresh, Document
} from '@element-plus/icons-vue'
// Router
const router = useRouter()
// 響應式數據
const loading = ref(false)
const jobs = ref([])
const users = ref([])
const pagination = ref({
page: 1,
per_page: 20,
total: 0,
pages: 0
})
const filters = ref({
user_id: 'all',
status: 'all',
search: ''
})
// 語言映射
const languageMap = {
'zh-TW': '繁體中文',
'zh-CN': '簡體中文',
'en': '英語',
'ja': '日語',
'ko': '韓語',
'es': '西班牙語',
'fr': '法語',
'de': '德語',
'pt': '葡萄牙語',
'ru': '俄語',
'ar': '阿拉伯語',
'hi': '印地語',
'th': '泰語',
'vi': '越南語',
'it': '義大利語',
'nl': '荷蘭語'
}
// 方法
const fetchJobs = async () => {
try {
loading.value = true
const params = {
page: pagination.value.page,
per_page: pagination.value.per_page,
status: filters.value.status,
search: filters.value.search
}
// 只有選擇特定用戶時才加入 user_id 參數
if (filters.value.user_id !== 'all' && filters.value.user_id) {
params.user_id = parseInt(filters.value.user_id)
}
const response = await adminAPI.getAllJobs(params)
if (response.success) {
jobs.value = response.data.jobs || []
pagination.value = response.data.pagination || pagination.value
}
} catch (error) {
console.error('取得任務列表失敗:', error)
ElMessage.error('載入任務列表失敗')
} finally {
loading.value = false
}
}
const fetchUsers = async () => {
try {
const response = await adminAPI.getUsers()
if (response.success) {
users.value = response.data.users || []
}
} catch (error) {
console.error('取得用戶列表失敗:', error)
}
}
const refreshJobs = async () => {
await fetchJobs()
}
const handleFilterChange = () => {
pagination.value.page = 1
fetchJobs()
}
const handlePageChange = () => {
fetchJobs()
}
const handleSizeChange = () => {
pagination.value.page = 1
fetchJobs()
}
const viewJobDetail = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const cancelJob = async (jobUuid) => {
try {
await ElMessageBox.confirm(
'確定要取消這個任務嗎?',
'取消任務',
{
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await adminAPI.adminCancelJob(jobUuid)
if (response.success) {
ElMessage.success('任務已取消')
await refreshJobs()
}
} catch (error) {
if (error !== 'cancel') {
console.error('取消任務失敗:', error)
ElMessage.error(error.response?.data?.message || '取消任務失敗')
}
}
}
const deleteJob = async (jobUuid) => {
try {
await ElMessageBox.confirm(
'確定要刪除這個任務嗎?刪除後將無法恢復',
'刪除任務',
{
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await adminAPI.adminDeleteJob(jobUuid)
if (response.success) {
ElMessage.success('任務已刪除')
await refreshJobs()
}
} catch (error) {
if (error !== 'cancel') {
console.error('刪除任務失敗:', error)
ElMessage.error(error.response?.data?.message || '刪除任務失敗')
}
}
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
const getLanguageText = (langCode) => {
return languageMap[langCode] || langCode
}
const getStatusText = (status) => {
const statusMap = {
'PENDING': '等待',
'PROCESSING': '處理中',
'COMPLETED': '完成',
'FAILED': '失敗',
'RETRY': '重試'
}
return statusMap[status] || status
}
const getStatusTagType = (status) => {
const typeMap = {
'PENDING': 'info',
'PROCESSING': 'primary',
'COMPLETED': 'success',
'FAILED': 'danger',
'RETRY': 'warning'
}
return typeMap[status] || 'info'
}
const formatTime = (timestamp) => {
if (!timestamp) return ''
const now = new Date()
const time = new Date(timestamp)
const diff = now - time
if (diff < 60000) return '剛剛'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}時前`
if (diff < 2592000000) return `${Math.floor(diff / 86400000)}天前`
return time.toLocaleDateString('zh-TW')
}
// 生命週期
onMounted(async () => {
await Promise.all([
fetchUsers(),
fetchJobs()
])
})
</script>
<style lang="scss" scoped>
.admin-jobs-view {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
}
.page-actions {
display: flex;
gap: 12px;
}
}
.filter-section {
margin-bottom: 24px;
.filter-row {
display: grid;
grid-template-columns: 200px 150px 1fr;
gap: 16px;
align-items: end;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-regular);
}
}
}
}
.jobs-section {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.card-info {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
.jobs-table {
.file-info {
display: flex;
align-items: center;
gap: 8px;
.file-icon {
width: 32px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
background: var(--el-color-primary);
flex-shrink: 0;
}
.file-name {
word-break: break-all;
line-height: 1.4;
}
}
.language-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.pagination-wrapper {
margin-top: 24px;
display: flex;
justify-content: center;
}
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--el-text-color-secondary);
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-title {
font-size: 16px;
margin-bottom: 8px;
}
}
.loading-state {
padding: 24px;
}
}
}
.content-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.card-header {
padding: 20px 24px 0;
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
}
}
.card-body {
padding: 20px 24px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,840 @@
<template>
<div class="history-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">歷史記錄</h1>
<div class="page-actions">
<el-button @click="exportHistory">
<el-icon><Download /></el-icon>
匯出記錄
</el-button>
</div>
</div>
<!-- 篩選區域 -->
<div class="content-card">
<div class="filters-section">
<div class="filters-row">
<div class="filter-group">
<label>時間範圍:</label>
<el-date-picker
v-model="dateRange"
type="daterange"
start-placeholder="開始日期"
end-placeholder="結束日期"
format="YYYY/MM/DD"
value-format="YYYY-MM-DD"
@change="handleDateRangeChange"
/>
</div>
<div class="filter-group">
<label>狀態:</label>
<el-select v-model="filters.status" @change="handleFilterChange">
<el-option label="全部" value="all" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="失敗" value="FAILED" />
</el-select>
</div>
<div class="filter-group">
<label>檔案類型:</label>
<el-select v-model="filters.fileType" @change="handleFilterChange">
<el-option label="全部" value="all" />
<el-option label="Word" value="doc" />
<el-option label="PowerPoint" value="ppt" />
<el-option label="Excel" value="xls" />
<el-option label="PDF" value="pdf" />
</el-select>
</div>
<div class="filter-actions">
<el-button @click="clearFilters">
<el-icon><Close /></el-icon>
清除篩選
</el-button>
</div>
</div>
<div class="search-row">
<el-input
v-model="filters.search"
placeholder="搜尋檔案名稱..."
style="width: 300px"
clearable
@input="handleSearchChange"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
</div>
<!-- 統計概覽 -->
<div class="stats-section">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">
<el-icon><Files /></el-icon>
</div>
<div class="stat-value">{{ filteredJobs.length }}</div>
<div class="stat-label">總記錄數</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<el-icon><SuccessFilled /></el-icon>
</div>
<div class="stat-value">{{ completedCount }}</div>
<div class="stat-label">成功完成</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">
<el-icon><Money /></el-icon>
</div>
<div class="stat-value">${{ totalCost.toFixed(4) }}</div>
<div class="stat-label">總成本</div>
</div>
<div class="stat-card">
<div class="stat-icon info">
<el-icon><Clock /></el-icon>
</div>
<div class="stat-value">{{ avgProcessingTime }}</div>
<div class="stat-label">平均處理時間</div>
</div>
</div>
</div>
<!-- 歷史記錄列表 -->
<div class="content-card">
<div class="card-body">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="filteredJobs.length === 0" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<div class="empty-title">無歷史記錄</div>
<div class="empty-description">
在所選時間範圍內沒有找到符合條件的記錄
</div>
</div>
<div v-else>
<!-- 表格模式 -->
<div class="view-toggle">
<el-radio-group v-model="viewMode">
<el-radio-button label="table">表格檢視</el-radio-button>
<el-radio-button label="card">卡片檢視</el-radio-button>
</el-radio-group>
</div>
<!-- 表格檢視 -->
<div v-if="viewMode === 'table'" class="table-view">
<el-table :data="paginatedJobs" style="width: 100%">
<el-table-column prop="original_filename" label="檔案名稱" min-width="200">
<template #default="{ row }">
<div class="file-info">
<div class="file-icon" :class="getFileExtension(row.original_filename)">
{{ getFileExtension(row.original_filename).toUpperCase() }}
</div>
<span class="file-name">{{ row.original_filename }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="file_size" label="檔案大小" width="100">
<template #default="{ row }">
{{ formatFileSize(row.file_size) }}
</template>
</el-table-column>
<el-table-column prop="target_languages" label="翻譯語言" width="150">
<template #default="{ row }">
<div class="language-tags">
<el-tag
v-for="lang in row.target_languages.slice(0, 2)"
:key="lang"
size="small"
type="primary"
>
{{ getLanguageText(lang) }}
</el-tag>
<el-tag v-if="row.target_languages.length > 2" size="small" type="info">
+{{ row.target_languages.length - 2 }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="狀態" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_cost" label="成本" width="100">
<template #default="{ row }">
${{ (row.total_cost || 0).toFixed(4) }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="建立時間" width="130">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="completed_at" label="完成時間" width="130">
<template #default="{ row }">
{{ row.completed_at ? formatDate(row.completed_at) : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button type="text" size="small" @click="viewJobDetail(row.job_uuid)">
查看
</el-button>
<el-button
v-if="row.status === 'COMPLETED'"
type="text"
size="small"
@click="downloadJob(row)"
>
下載
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<!-- 卡片檢視 -->
<div v-else class="card-view">
<div class="history-cards">
<div
v-for="job in paginatedJobs"
:key="job.job_uuid"
class="history-card"
@click="viewJobDetail(job.job_uuid)"
>
<div class="card-header">
<div class="file-info">
<div class="file-icon" :class="getFileExtension(job.original_filename)">
{{ getFileExtension(job.original_filename).toUpperCase() }}
</div>
<div class="file-details">
<div class="file-name">{{ job.original_filename }}</div>
<div class="file-meta">
{{ formatFileSize(job.file_size) }}
{{ formatDate(job.created_at) }}
</div>
</div>
</div>
<div class="card-status">
<el-tag :type="getStatusTagType(job.status)" size="small">
{{ getStatusText(job.status) }}
</el-tag>
</div>
</div>
<div class="card-content">
<div class="languages-section">
<div class="language-label">翻譯語言:</div>
<div class="language-tags">
<span
v-for="lang in job.target_languages"
:key="lang"
class="language-tag"
>
{{ getLanguageText(lang) }}
</span>
</div>
</div>
<div class="stats-section">
<div class="stat-item" v-if="job.total_cost > 0">
<span class="stat-label">成本:</span>
<span class="stat-value">${{ job.total_cost.toFixed(4) }}</span>
</div>
<div class="stat-item" v-if="job.total_tokens > 0">
<span class="stat-label">Token:</span>
<span class="stat-value">{{ job.total_tokens.toLocaleString() }}</span>
</div>
</div>
</div>
<div class="card-footer" v-if="job.completed_at || job.processing_started_at">
<div class="time-info">
<div v-if="job.processing_started_at && job.completed_at">
處理時間: {{ calculateProcessingTime(job.processing_started_at, job.completed_at) }}
</div>
<div v-if="job.completed_at">
完成時間: {{ formatTime(job.completed_at) }}
</div>
</div>
<div class="card-actions" @click.stop>
<el-button
v-if="job.status === 'COMPLETED'"
type="primary"
size="small"
@click="downloadJob(job)"
>
下載
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 分頁 -->
<div class="pagination-section" v-if="totalPages > 1">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredJobs.length"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage } from 'element-plus'
import { debounce } from 'lodash-es'
import {
Download, Close, Search, Files, SuccessFilled, Money, Clock, Document
} from '@element-plus/icons-vue'
// Router 和 Store
const router = useRouter()
const jobsStore = useJobsStore()
// 響應式數據
const loading = ref(false)
const viewMode = ref('table')
const dateRange = ref([])
const currentPage = ref(1)
const pageSize = ref(20)
const filters = ref({
status: 'all',
fileType: 'all',
search: ''
})
// 語言映射
const languageMap = {
'zh-TW': '繁中',
'zh-CN': '簡中',
'en': '英文',
'ja': '日文',
'ko': '韓文',
'vi': '越文'
}
// 計算屬性
const allJobs = computed(() => jobsStore.jobs.filter(job =>
job.status === 'COMPLETED' || job.status === 'FAILED'
))
const filteredJobs = computed(() => {
let jobs = allJobs.value
// 狀態篩選
if (filters.value.status !== 'all') {
jobs = jobs.filter(job => job.status === filters.value.status)
}
// 檔案類型篩選
if (filters.value.fileType !== 'all') {
jobs = jobs.filter(job => {
const ext = getFileExtension(job.original_filename)
switch (filters.value.fileType) {
case 'doc': return ['docx', 'doc'].includes(ext)
case 'ppt': return ['pptx', 'ppt'].includes(ext)
case 'xls': return ['xlsx', 'xls'].includes(ext)
case 'pdf': return ext === 'pdf'
default: return true
}
})
}
// 日期範圍篩選
if (dateRange.value && dateRange.value.length === 2) {
const [startDate, endDate] = dateRange.value
jobs = jobs.filter(job => {
const jobDate = new Date(job.created_at).toDateString()
return jobDate >= new Date(startDate).toDateString() &&
jobDate <= new Date(endDate).toDateString()
})
}
// 搜尋篩選
if (filters.value.search.trim()) {
const searchTerm = filters.value.search.toLowerCase().trim()
jobs = jobs.filter(job =>
job.original_filename.toLowerCase().includes(searchTerm)
)
}
return jobs.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
})
const paginatedJobs = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredJobs.value.slice(start, start + pageSize.value)
})
const totalPages = computed(() => Math.ceil(filteredJobs.value.length / pageSize.value))
const completedCount = computed(() =>
filteredJobs.value.filter(job => job.status === 'COMPLETED').length
)
const totalCost = computed(() =>
filteredJobs.value.reduce((sum, job) => sum + (job.total_cost || 0), 0)
)
const avgProcessingTime = computed(() => {
const completedJobs = filteredJobs.value.filter(job =>
job.status === 'COMPLETED' && job.processing_started_at && job.completed_at
)
if (completedJobs.length === 0) return '無資料'
const totalMs = completedJobs.reduce((sum, job) => {
const startTime = new Date(job.processing_started_at)
const endTime = new Date(job.completed_at)
return sum + (endTime - startTime)
}, 0)
const avgMs = totalMs / completedJobs.length
const minutes = Math.floor(avgMs / 60000)
const seconds = Math.floor((avgMs % 60000) / 1000)
return `${minutes}${seconds}`
})
// 方法
const handleFilterChange = () => {
currentPage.value = 1
}
const handleSearchChange = debounce(() => {
currentPage.value = 1
}, 300)
const handleDateRangeChange = () => {
currentPage.value = 1
}
const handlePageChange = (page) => {
currentPage.value = page
}
const clearFilters = () => {
filters.value.status = 'all'
filters.value.fileType = 'all'
filters.value.search = ''
dateRange.value = []
currentPage.value = 1
}
const viewJobDetail = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const downloadJob = async (job) => {
try {
if (job.target_languages.length === 1) {
const ext = getFileExtension(job.original_filename)
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_${job.target_languages[0]}_translated.${ext}`
await jobsStore.downloadFile(job.job_uuid, job.target_languages[0], filename)
} else {
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_translated.zip`
await jobsStore.downloadAllFiles(job.job_uuid, filename)
}
} catch (error) {
console.error('下載失敗:', error)
}
}
const exportHistory = () => {
// 匯出 CSV 格式的歷史記錄
const csvContent = [
['檔案名稱', '檔案大小', '目標語言', '狀態', '成本', '建立時間', '完成時間'].join(','),
...filteredJobs.value.map(job => [
`"${job.original_filename}"`,
formatFileSize(job.file_size),
`"${job.target_languages.join(', ')}"`,
getStatusText(job.status),
(job.total_cost || 0).toFixed(4),
formatDate(job.created_at),
job.completed_at ? formatDate(job.completed_at) : ''
].join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `翻譯歷史記錄_${new Date().toISOString().slice(0, 10)}.csv`)
link.click()
ElMessage.success('歷史記錄已匯出')
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const formatDate = (timestamp) => {
return new Date(timestamp).toLocaleDateString('zh-TW', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const formatTime = (timestamp) => {
const now = new Date()
const time = new Date(timestamp)
const diff = now - time
if (diff < 86400000) return '今天'
if (diff < 172800000) return '昨天'
if (diff < 2592000000) return `${Math.floor(diff / 86400000)}天前`
return time.toLocaleDateString('zh-TW')
}
const calculateProcessingTime = (startTime, endTime) => {
const start = new Date(startTime)
const end = new Date(endTime)
const diff = end - start
const minutes = Math.floor(diff / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
return `${minutes}${seconds}`
}
const getLanguageText = (langCode) => {
return languageMap[langCode] || langCode
}
const getStatusText = (status) => {
const statusMap = {
'COMPLETED': '已完成',
'FAILED': '失敗'
}
return statusMap[status] || status
}
const getStatusTagType = (status) => {
const typeMap = {
'COMPLETED': 'success',
'FAILED': 'danger'
}
return typeMap[status] || 'info'
}
// 生命週期
onMounted(async () => {
loading.value = true
try {
await jobsStore.fetchJobs({ per_page: 100 })
} catch (error) {
console.error('載入歷史記錄失敗:', error)
} finally {
loading.value = false
}
})
// 監聽檢視模式變化,重置分頁
watch(viewMode, () => {
currentPage.value = 1
})
</script>
<style lang="scss" scoped>
.history-view {
.filters-section {
.filters-row {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 16px;
flex-wrap: wrap;
.filter-group {
display: flex;
align-items: center;
gap: 8px;
label {
font-size: 14px;
color: var(--el-text-color-regular);
white-space: nowrap;
}
}
.filter-actions {
margin-left: auto;
@media (max-width: 768px) {
margin-left: 0;
width: 100%;
}
}
}
.search-row {
display: flex;
justify-content: flex-start;
}
}
.stats-section {
margin: 24px 0;
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
}
.view-toggle {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
}
.table-view {
.file-info {
display: flex;
align-items: center;
gap: 8px;
.file-icon {
width: 24px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: bold;
color: white;
flex-shrink: 0;
&.docx, &.doc { background-color: #2b579a; }
&.pptx, &.ppt { background-color: #d24726; }
&.xlsx, &.xls { background-color: #207245; }
&.pdf { background-color: #ff0000; }
}
.file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.language-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.table-actions {
display: flex;
gap: 8px;
}
}
.card-view {
.history-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 16px;
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
.history-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 16px;
background: var(--el-bg-color);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
.file-info {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
min-width: 0;
.file-icon {
width: 36px;
height: 36px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
flex-shrink: 0;
&.docx, &.doc { background-color: #2b579a; }
&.pptx, &.ppt { background-color: #d24726; }
&.xlsx, &.xls { background-color: #207245; }
&.pdf { background-color: #ff0000; }
}
.file-details {
flex: 1;
min-width: 0;
.file-name {
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
}
.card-content {
margin-bottom: 12px;
.languages-section {
margin-bottom: 8px;
.language-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.language-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
.language-tag {
display: inline-block;
padding: 2px 6px;
background-color: var(--el-color-primary-light-8);
color: var(--el-color-primary);
border: 1px solid var(--el-color-primary-light-5);
border-radius: 3px;
font-size: 11px;
font-weight: 500;
}
}
}
.stats-section {
display: flex;
gap: 16px;
font-size: 12px;
.stat-item {
display: flex;
gap: 4px;
.stat-label {
color: var(--el-text-color-secondary);
}
.stat-value {
color: var(--el-text-color-primary);
font-weight: 500;
}
}
}
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
.time-info {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
}
}
}
}
.pagination-section {
margin-top: 24px;
display: flex;
justify-content: center;
}
}
.loading-state {
padding: 40px 0;
}
</style>

View File

@@ -0,0 +1,561 @@
<template>
<div class="home-view">
<!-- 歡迎區域 -->
<div class="welcome-section">
<div class="welcome-card content-card">
<div class="welcome-content">
<div class="welcome-text">
<h1 class="welcome-title">
歡迎使用 PANJIT 文件翻譯系統
<el-tag v-if="authStore.isAdmin" type="warning" size="small">管理員</el-tag>
</h1>
<p class="welcome-subtitle">
歡迎回來{{ authStore.userName }}
今天是個適合處理翻譯任務的好日子
</p>
</div>
<div class="welcome-actions">
<el-button type="primary" size="large" @click="$router.push('/upload')">
<el-icon><Upload /></el-icon>
開始上傳檔案
</el-button>
<el-button size="large" @click="$router.push('/jobs')">
<el-icon><List /></el-icon>
查看我的任務
</el-button>
</div>
</div>
</div>
</div>
<!-- 統計概覽 -->
<div class="stats-section">
<div class="section-title">
<h2>任務統計</h2>
<el-button type="text" @click="refreshStats">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">
<el-icon><Files /></el-icon>
</div>
<div class="stat-value">{{ jobStats.total }}</div>
<div class="stat-label">總任務數</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">
<el-icon><Clock /></el-icon>
</div>
<div class="stat-value">{{ jobStats.pending + jobStats.processing }}</div>
<div class="stat-label">處理中</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<el-icon><SuccessFilled /></el-icon>
</div>
<div class="stat-value">{{ jobStats.completed }}</div>
<div class="stat-label">已完成</div>
</div>
<div class="stat-card">
<div class="stat-icon danger">
<el-icon><CircleCloseFilled /></el-icon>
</div>
<div class="stat-value">{{ jobStats.failed }}</div>
<div class="stat-label">失敗</div>
</div>
</div>
</div>
<!-- 最近任務 -->
<div class="recent-jobs-section">
<div class="content-card">
<div class="card-header">
<h3 class="card-title">最近任務</h3>
<div class="card-actions">
<el-button type="text" @click="$router.push('/jobs')">
查看全部
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
</div>
<div class="card-body">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="3" animated />
</div>
<div v-else-if="recentJobs.length === 0" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<div class="empty-title">暫無任務記錄</div>
<div class="empty-description">
開始上傳您的第一個檔案進行翻譯吧
</div>
<el-button type="primary" @click="$router.push('/upload')">
立即上傳
</el-button>
</div>
<div v-else class="job-list">
<div
v-for="job in recentJobs"
:key="job.job_uuid"
class="job-item"
@click="viewJobDetail(job.job_uuid)"
>
<div class="job-icon">
<div class="file-icon" :class="getFileExtension(job.original_filename)">
{{ getFileExtension(job.original_filename).toUpperCase() }}
</div>
</div>
<div class="job-info">
<div class="job-name">{{ job.original_filename }}</div>
<div class="job-details">
<span class="job-size">{{ formatFileSize(job.file_size) }}</span>
<span class="job-languages">
{{ job.target_languages.join(', ') }}
</span>
</div>
</div>
<div class="job-status">
<div class="status-badge" :class="job.status.toLowerCase()">
{{ getStatusText(job.status) }}
</div>
<div v-if="job.progress > 0 && job.status === 'PROCESSING'" class="job-progress">
<el-progress
:percentage="job.progress"
:stroke-width="4"
:show-text="false"
color="#409eff"
/>
</div>
</div>
<div class="job-time">
{{ formatTime(job.created_at) }}
</div>
<div class="job-actions" @click.stop>
<el-dropdown trigger="click" @command="handleJobAction($event, job)">
<el-button type="text" size="small">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">查看詳情</el-dropdown-item>
<el-dropdown-item
v-if="job.status === 'COMPLETED'"
command="download"
>
下載檔案
</el-dropdown-item>
<el-dropdown-item
v-if="job.status === 'FAILED'"
command="retry"
>
重新翻譯
</el-dropdown-item>
<el-dropdown-item
v-if="job.status === 'PENDING' || job.status === 'PROCESSING'"
command="cancel"
>
取消任務
</el-dropdown-item>
<el-dropdown-item command="delete" divided>刪除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Upload, List, Refresh, Files, Clock, SuccessFilled, CircleCloseFilled,
ArrowRight, Document, More
} from '@element-plus/icons-vue'
// Router 和 Stores
const router = useRouter()
const authStore = useAuthStore()
const jobsStore = useJobsStore()
// 響應式數據
const loading = ref(false)
// 計算屬性
const jobStats = computed(() => jobsStore.jobStats)
const recentJobs = computed(() => {
return jobsStore.jobs.slice(0, 5)
})
// 方法
const refreshStats = async () => {
try {
await jobsStore.fetchJobs({ per_page: 10 })
ElMessage.success('統計資料已刷新')
} catch (error) {
console.error('刷新統計失敗:', error)
}
}
const viewJobDetail = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const handleJobAction = async (action, job) => {
switch (action) {
case 'view':
viewJobDetail(job.job_uuid)
break
case 'download':
// 如果只有一個目標語言,直接下載
if (job.target_languages.length === 1) {
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_${job.target_languages[0]}_translated.${getFileExtension(job.original_filename)}`
await jobsStore.downloadFile(job.job_uuid, job.target_languages[0], filename)
} else {
// 多個語言,下載打包檔案
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_translated.zip`
await jobsStore.downloadAllFiles(job.job_uuid, filename)
}
break
case 'retry':
try {
await ElMessageBox.confirm('確定要重新翻譯此檔案嗎?', '確認重試', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
await jobsStore.retryJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('重試任務失敗:', error)
}
}
break
case 'cancel':
try {
const statusText = job.status === 'PROCESSING' ? '處理中' : '等待中'
await ElMessageBox.confirm(
`確定要取消這個${statusText}的任務嗎?`,
'確認取消',
{
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
}
)
await jobsStore.cancelJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('取消任務失敗:', error)
}
}
break
case 'delete':
try {
await ElMessageBox.confirm('確定要刪除此任務嗎?此操作無法撤銷。', '確認刪除', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
await jobsStore.deleteJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('刪除任務失敗:', error)
}
}
break
}
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const getStatusText = (status) => {
const statusMap = {
'PENDING': '等待中',
'PROCESSING': '處理中',
'COMPLETED': '已完成',
'FAILED': '失敗',
'RETRY': '重試中'
}
return statusMap[status] || status
}
const formatTime = (timestamp) => {
const now = new Date()
const time = new Date(timestamp)
const diff = now - time
if (diff < 60000) return '剛剛'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分鐘前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小時前`
if (diff < 2592000000) return `${Math.floor(diff / 86400000)} 天前`
return time.toLocaleDateString('zh-TW')
}
// 生命週期
onMounted(async () => {
loading.value = true
console.log('🏠 [HomeView] onMounted 開始執行')
console.log('🏠 [HomeView] 當前認證狀態', {
isAuthenticated: authStore.isAuthenticated,
user: authStore.user,
hasToken: !!authStore.token
})
// 如果沒有認證,不要嘗試載入資料
if (!authStore.isAuthenticated || !authStore.token) {
console.warn('🏠 [HomeView] 未認證,跳過資料載入')
loading.value = false
return
}
try {
// 延遲載入任務列表,避免登入後立即請求造成認證問題
console.log('🏠 [HomeView] 等待 500ms 後載入任務列表')
await new Promise(resolve => setTimeout(resolve, 500))
// 再次檢查認證狀態(可能在延遲期間已登出)
if (!authStore.isAuthenticated) {
console.log('🏠 [HomeView] 認證狀態已改變,取消載入')
return
}
console.log('🏠 [HomeView] 開始載入任務列表')
// 載入最近的任務
await jobsStore.fetchJobs({ per_page: 10 })
console.log('🏠 [HomeView] 任務列表載入成功')
} catch (error) {
console.error('❌ [HomeView] 載入任務失敗:', error)
// 如果是認證錯誤,不顯示額外的錯誤訊息
if (error.response?.status !== 401 &&
!error.message?.includes('認證') &&
!error.message?.includes('401')) {
ElMessage.error('載入任務失敗,請稍後重試')
}
} finally {
loading.value = false
console.log('🏠 [HomeView] onMounted 執行完畢')
}
})
</script>
<style lang="scss" scoped>
.home-view {
.welcome-section {
margin-bottom: 32px;
.welcome-card {
background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
color: white;
border: none;
.welcome-content {
display: flex;
justify-content: space-between;
align-items: center;
@media (max-width: 768px) {
flex-direction: column;
text-align: center;
gap: 24px;
}
.welcome-text {
.welcome-title {
font-size: 28px;
font-weight: bold;
margin: 0 0 12px 0;
display: flex;
align-items: center;
gap: 12px;
@media (max-width: 768px) {
font-size: 24px;
justify-content: center;
flex-wrap: wrap;
}
}
.welcome-subtitle {
font-size: 16px;
opacity: 0.9;
margin: 0;
line-height: 1.5;
}
}
.welcome-actions {
display: flex;
gap: 12px;
@media (max-width: 480px) {
flex-direction: column;
width: 100%;
}
}
}
}
}
.stats-section {
margin-bottom: 32px;
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 20px;
font-weight: 600;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
@media (max-width: 480px) {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
}
.recent-jobs-section {
margin-bottom: 32px;
.job-list {
.job-item {
display: flex;
align-items: center;
padding: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&:last-child {
margin-bottom: 0;
}
.job-icon {
margin-right: 16px;
}
.job-info {
flex: 1;
min-width: 0;
.job-name {
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-details {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--el-text-color-secondary);
@media (max-width: 480px) {
flex-direction: column;
gap: 2px;
}
}
}
.job-status {
margin: 0 16px;
text-align: center;
min-width: 80px;
.job-progress {
margin-top: 8px;
width: 60px;
}
}
.job-time {
min-width: 80px;
text-align: right;
font-size: 13px;
color: var(--el-text-color-secondary);
@media (max-width: 768px) {
display: none;
}
}
.job-actions {
margin-left: 16px;
}
}
}
}
}
.loading-state {
padding: 20px 0;
}
</style>

View File

@@ -0,0 +1,917 @@
<template>
<div class="job-detail-view">
<!-- 載入狀態 -->
<div v-if="loading" class="loading-wrapper">
<el-skeleton :rows="8" animated />
</div>
<!-- 任務不存在 -->
<div v-else-if="!job" class="not-found">
<div class="not-found-content">
<el-icon class="not-found-icon"><DocumentDelete /></el-icon>
<h2>任務不存在</h2>
<p>抱歉無法找到指定的翻譯任務</p>
<el-button type="primary" @click="$router.push('/jobs')">
返回任務列表
</el-button>
</div>
</div>
<!-- 任務詳情 -->
<div v-else class="job-detail-content">
<!-- 頁面標題 -->
<div class="page-header">
<div class="header-left">
<el-button type="text" @click="$router.back()" class="back-button">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h1 class="page-title">任務詳情</h1>
</div>
<div class="page-actions">
<el-button @click="refreshJob" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-dropdown @command="handleAction" v-if="job.status === 'COMPLETED'">
<el-button type="primary">
<el-icon><Download /></el-icon>
下載
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="lang in job.target_languages"
:key="lang"
:command="`download_${lang}`"
>
下載 {{ getLanguageText(lang) }} 版本
</el-dropdown-item>
<el-dropdown-item
v-if="hasCombinedFile"
command="download_combined"
divided
>
下載合併檔案
</el-dropdown-item>
<el-dropdown-item command="download_all" divided>
下載全部檔案 (ZIP)
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 任務基本資訊 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">基本資訊</h3>
<div class="job-status-badge">
<el-tag
:type="getStatusTagType(job.status)"
size="large"
effect="dark"
>
<el-icon>
<component :is="getStatusIcon(job.status)" />
</el-icon>
{{ getStatusText(job.status) }}
</el-tag>
</div>
</div>
<div class="card-body">
<div class="job-info-grid">
<div class="info-section">
<div class="section-title">檔案資訊</div>
<div class="info-items">
<div class="info-item">
<div class="info-icon">
<div class="file-icon" :class="getFileExtension(job.original_filename)">
{{ getFileExtension(job.original_filename).toUpperCase() }}
</div>
</div>
<div class="info-content">
<div class="info-label">檔案名稱</div>
<div class="info-value">{{ job.original_filename }}</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">
<el-icon><Document /></el-icon>
</div>
<div class="info-content">
<div class="info-label">檔案大小</div>
<div class="info-value">{{ formatFileSize(job.file_size) }}</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">
<el-icon><Key /></el-icon>
</div>
<div class="info-content">
<div class="info-label">任務 ID</div>
<div class="info-value job-uuid">{{ job.job_uuid }}</div>
</div>
</div>
</div>
</div>
<div class="info-section">
<div class="section-title">翻譯設定</div>
<div class="info-items">
<div class="info-item">
<div class="info-icon">
<el-icon><Switch /></el-icon>
</div>
<div class="info-content">
<div class="info-label">來源語言</div>
<div class="info-value">
<el-tag size="small" type="info">
{{ getLanguageText(job.source_language) }}
</el-tag>
</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">
<el-icon><Rank /></el-icon>
</div>
<div class="info-content">
<div class="info-label">目標語言</div>
<div class="info-value">
<div class="language-tags">
<el-tag
v-for="lang in job.target_languages"
:key="lang"
size="small"
type="primary"
>
{{ getLanguageText(lang) }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 處理進度 -->
<div class="content-card" v-if="job.status === 'PROCESSING' || job.status === 'RETRY'">
<div class="card-header">
<h3 class="card-title">處理進度</h3>
</div>
<div class="card-body">
<div class="progress-section">
<div class="progress-info">
<span>翻譯進度</span>
<span>{{ Math.round(job.progress || 0) }}%</span>
</div>
<el-progress
:percentage="job.progress || 0"
:stroke-width="12"
status="success"
/>
<div class="progress-description">
系統正在處理您的檔案請耐心等待...
</div>
</div>
</div>
</div>
<!-- 錯誤資訊 -->
<div class="content-card" v-if="job.status === 'FAILED' && job.error_message">
<div class="card-header">
<h3 class="card-title">錯誤資訊</h3>
<div class="card-actions">
<el-button type="primary" @click="retryJob" :loading="retrying">
<el-icon><RefreshRight /></el-icon>
重新翻譯
</el-button>
</div>
</div>
<div class="card-body">
<el-alert
:title="job.error_message"
type="error"
show-icon
:closable="false"
>
<template #default>
<div class="error-details">
<p>{{ job.error_message }}</p>
<p v-if="job.retry_count > 0" class="retry-info">
已重試 {{ job.retry_count }}
</p>
</div>
</template>
</el-alert>
</div>
</div>
<!-- 時間軸 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">處理時間軸</h3>
</div>
<div class="card-body">
<el-timeline>
<el-timeline-item
timestamp="建立任務"
:time="formatDateTime(job.created_at)"
type="primary"
size="large"
icon="Plus"
>
任務建立成功檔案已上傳至系統
</el-timeline-item>
<el-timeline-item
v-if="job.processing_started_at"
timestamp="開始處理"
:time="formatDateTime(job.processing_started_at)"
type="warning"
size="large"
icon="Loading"
>
系統開始處理翻譯任務
</el-timeline-item>
<el-timeline-item
v-if="job.completed_at"
timestamp="處理完成"
:time="formatDateTime(job.completed_at)"
type="success"
size="large"
icon="Check"
>
翻譯完成檔案可供下載
<div v-if="job.processing_started_at" class="processing-time">
處理耗時: {{ calculateProcessingTime(job.processing_started_at, job.completed_at) }}
</div>
</el-timeline-item>
<el-timeline-item
v-else-if="job.status === 'FAILED'"
timestamp="處理失敗"
time="發生錯誤"
type="danger"
size="large"
icon="Close"
>
翻譯過程中發生錯誤
</el-timeline-item>
</el-timeline>
</div>
</div>
<!-- 成本統計 -->
<div class="content-card" v-if="job.total_cost > 0 || job.total_tokens > 0">
<div class="card-header">
<h3 class="card-title">成本統計</h3>
</div>
<div class="card-body">
<div class="cost-stats">
<div class="cost-item" v-if="job.total_tokens > 0">
<div class="cost-icon">
<el-icon><Coin /></el-icon>
</div>
<div class="cost-info">
<div class="cost-label">使用 Token</div>
<div class="cost-value">{{ job.total_tokens.toLocaleString() }}</div>
</div>
</div>
<div class="cost-item" v-if="job.total_cost > 0">
<div class="cost-icon">
<el-icon><Money /></el-icon>
</div>
<div class="cost-info">
<div class="cost-label">總成本</div>
<div class="cost-value">${{ job.total_cost.toFixed(6) }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 檔案列表 -->
<div class="content-card" v-if="jobFiles.length > 0">
<div class="card-header">
<h3 class="card-title">相關檔案</h3>
</div>
<div class="card-body">
<div class="files-list">
<div
v-for="file in jobFiles"
:key="`${file.file_type}_${file.language_code || 'original'}`"
class="file-item"
>
<div class="file-icon" :class="getFileExtension(file.filename)">
{{ getFileExtension(file.filename).toUpperCase() }}
</div>
<div class="file-info">
<div class="file-name">{{ file.filename }}</div>
<div class="file-details">
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
<span class="file-type">
{{ file.file_type === 'ORIGINAL' ? '原始檔案' :
file.language_code === 'combined' ? '組合翻譯檔案 (多語言)' :
`翻譯檔案 (${getLanguageText(file.language_code)})` }}
</span>
</div>
</div>
<div class="file-actions">
<el-button
v-if="file.file_type === 'TRANSLATED'"
type="primary"
size="small"
@click="file.language_code === 'combined' ? downloadCombinedFile() : downloadFile(file.language_code, file.filename)"
>
<el-icon><Download /></el-icon>
下載
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useJobsStore } from '@/stores/jobs'
import { jobsAPI, filesAPI } from '@/services/jobs'
import { ElMessage } from 'element-plus'
import {
DocumentDelete, ArrowLeft, Refresh, Download, ArrowDown,
Document, Key, Switch, Rank, RefreshRight, Plus, Loading,
Check, Close, Coin, Money
} from '@element-plus/icons-vue'
import { websocketService } from '@/utils/websocket'
// Router 和 Store
const route = useRoute()
const router = useRouter()
const jobsStore = useJobsStore()
// 響應式數據
const loading = ref(false)
const retrying = ref(false)
const job = ref(null)
const jobFiles = ref([])
// 語言映射
const languageMap = {
'auto': '自動偵測',
'zh-TW': '繁體中文',
'zh-CN': '簡體中文',
'en': '英文',
'ja': '日文',
'ko': '韓文',
'vi': '越南文'
}
// 計算屬性
const jobUuid = computed(() => route.params.uuid)
// 檢查是否有combined檔案
const hasCombinedFile = computed(() => {
return jobFiles.value.some(file =>
file.language_code === 'combined' ||
file.filename.toLowerCase().includes('combine')
)
})
// 方法
const loadJobDetail = async () => {
loading.value = true
try {
const response = await jobsStore.fetchJobDetail(jobUuid.value)
if (!response || !response.job) {
throw new Error('響應資料格式錯誤')
}
job.value = response.job
jobFiles.value = response.job.files || []
// 訂閱 WebSocket 狀態更新
if (['PENDING', 'PROCESSING', 'RETRY'].includes(job.value.status)) {
websocketService.subscribeToJob(jobUuid.value)
}
} catch (error) {
console.error('載入任務詳情失敗:', error)
ElMessage.error('載入任務詳情失敗')
} finally {
loading.value = false
}
}
const refreshJob = async () => {
await loadJobDetail()
ElMessage.success('任務資訊已刷新')
}
const retryJob = async () => {
retrying.value = true
try {
await jobsStore.retryJob(jobUuid.value)
await loadJobDetail()
ElMessage.success('任務已重新提交處理')
} catch (error) {
console.error('重試任務失敗:', error)
} finally {
retrying.value = false
}
}
const handleAction = async (command) => {
if (command.startsWith('download_')) {
const langCode = command.replace('download_', '')
if (langCode === 'all') {
await downloadAllFiles()
} else if (langCode === 'combined') {
await downloadCombinedFile()
} else {
await downloadFile(langCode)
}
}
}
const downloadFile = async (langCode, customFilename = null) => {
try {
const ext = getFileExtension(job.value.original_filename)
const filename = customFilename || `${job.value.original_filename.replace(/\.[^/.]+$/, '')}_${langCode}_translated.${ext}`
await jobsStore.downloadFile(jobUuid.value, langCode, filename)
} catch (error) {
console.error('下載檔案失敗:', error)
}
}
const downloadCombinedFile = async () => {
try {
// 使用新的 combine 下載 API
const response = await filesAPI.downloadCombineFile(jobUuid.value)
// 從響應頭獲取檔案名
let filename = 'combined_file.docx'
if (response.headers && response.headers['content-disposition']) {
const contentDisposition = response.headers['content-disposition']
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match) {
filename = match[1].replace(/['"]/g, '')
}
} else {
// 使用預設檔名或從任務資料獲取
const originalName = job.value.original_filename
if (originalName) {
const nameParts = originalName.split('.')
const baseName = nameParts.slice(0, -1).join('.')
const extension = nameParts[nameParts.length - 1]
filename = `combined_${baseName}.${extension}`
} else {
filename = 'combined_file.docx'
}
}
// 創建下載連結
const blobData = response.data || response
const url = window.URL.createObjectURL(new Blob([blobData]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', filename)
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
ElMessage.success('合併檔案下載成功')
} catch (error) {
console.error('下載合併檔案失敗:', error)
ElMessage.error('合併檔案下載失敗')
}
}
const downloadAllFiles = async () => {
try {
const filename = `${job.value.original_filename.replace(/\.[^/.]+$/, '')}_translated.zip`
await jobsStore.downloadAllFiles(jobUuid.value, filename)
} catch (error) {
console.error('批量下載失敗:', error)
}
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const formatDateTime = (timestamp) => {
if (!timestamp) return ''
return new Date(timestamp).toLocaleString('zh-TW', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const calculateProcessingTime = (startTime, endTime) => {
const start = new Date(startTime)
const end = new Date(endTime)
const diff = end - start
const hours = Math.floor(diff / 3600000)
const minutes = Math.floor((diff % 3600000) / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
if (hours > 0) {
return `${hours}${minutes}${seconds}`
} else if (minutes > 0) {
return `${minutes}${seconds}`
} else {
return `${seconds}`
}
}
const getLanguageText = (langCode) => {
return languageMap[langCode] || langCode
}
const getStatusText = (status) => {
const statusMap = {
'PENDING': '等待處理',
'PROCESSING': '處理中',
'COMPLETED': '已完成',
'FAILED': '處理失敗',
'RETRY': '重試中'
}
return statusMap[status] || status
}
const getStatusTagType = (status) => {
const typeMap = {
'PENDING': 'info',
'PROCESSING': 'warning',
'COMPLETED': 'success',
'FAILED': 'danger',
'RETRY': 'warning'
}
return typeMap[status] || 'info'
}
const getStatusIcon = (status) => {
const iconMap = {
'PENDING': 'Clock',
'PROCESSING': 'Loading',
'COMPLETED': 'SuccessFilled',
'FAILED': 'CircleCloseFilled',
'RETRY': 'RefreshRight'
}
return iconMap[status] || 'InfoFilled'
}
// WebSocket 狀態更新處理
const handleJobStatusUpdate = (update) => {
if (job.value && update.job_uuid === job.value.job_uuid) {
Object.assign(job.value, update)
}
}
// 生命週期
onMounted(async () => {
await loadJobDetail()
// 監聽 WebSocket 狀態更新
websocketService.on('job_status', handleJobStatusUpdate)
})
onUnmounted(() => {
// 取消訂閱 WebSocket
if (job.value) {
websocketService.unsubscribeFromJob(job.value.job_uuid)
}
websocketService.off('job_status', handleJobStatusUpdate)
})
</script>
<style lang="scss" scoped>
.job-detail-view {
.loading-wrapper {
padding: 40px;
}
.not-found {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
.not-found-content {
text-align: center;
.not-found-icon {
font-size: 64px;
color: var(--el-color-info);
margin-bottom: 16px;
}
h2 {
margin: 0 0 8px 0;
color: var(--el-text-color-primary);
}
p {
margin: 0 0 24px 0;
color: var(--el-text-color-secondary);
}
}
}
.page-header {
.header-left {
display: flex;
align-items: center;
gap: 16px;
.back-button {
padding: 8px;
&:hover {
background-color: var(--el-color-primary-light-9);
}
}
.page-title {
margin: 0;
}
}
}
.job-status-badge {
.el-tag {
font-size: 14px;
padding: 8px 16px;
.el-icon {
margin-right: 4px;
}
}
}
.job-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 24px;
}
.info-section {
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.info-items {
.info-item {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.info-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.file-icon {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
&.docx, &.doc { background-color: #2b579a; }
&.pptx, &.ppt { background-color: #d24726; }
&.xlsx, &.xls { background-color: #207245; }
&.pdf { background-color: #ff0000; }
}
}
.info-content {
flex: 1;
min-width: 0;
.info-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
color: var(--el-text-color-primary);
font-weight: 500;
&.job-uuid {
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
}
.language-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
}
}
}
}
}
.progress-section {
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: var(--el-text-color-regular);
}
.progress-description {
margin-top: 8px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.error-details {
.retry-info {
margin-top: 8px;
font-size: 13px;
opacity: 0.8;
}
}
.processing-time {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.cost-stats {
display: flex;
gap: 24px;
@media (max-width: 480px) {
flex-direction: column;
gap: 16px;
}
.cost-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background-color: var(--el-fill-color-lighter);
border-radius: 8px;
flex: 1;
.cost-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--el-color-warning-light-9);
color: var(--el-color-warning);
display: flex;
align-items: center;
justify-content: center;
}
.cost-info {
.cost-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.cost-value {
font-size: 16px;
font-weight: bold;
color: var(--el-text-color-primary);
}
}
}
}
.files-list {
.file-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.file-icon {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
margin-right: 12px;
&.docx, &.doc { background-color: #2b579a; }
&.pptx, &.ppt { background-color: #d24726; }
&.xlsx, &.xls { background-color: #207245; }
&.pdf { background-color: #ff0000; }
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-details {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--el-text-color-secondary);
@media (max-width: 480px) {
flex-direction: column;
gap: 2px;
}
}
}
.file-actions {
margin-left: 16px;
}
}
}
}
</style>

View File

@@ -0,0 +1,894 @@
<template>
<div class="job-list-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">任務列表</h1>
<div class="page-actions">
<el-button type="primary" @click="$router.push('/upload')">
<el-icon><Upload /></el-icon>
上傳檔案
</el-button>
</div>
</div>
<!-- 篩選和搜尋 -->
<div class="content-card">
<div class="filters-section">
<div class="filters-row">
<div class="filter-group">
<label>狀態篩選:</label>
<el-select
v-model="filters.status"
@change="handleFilterChange"
style="width: 120px"
>
<el-option label="全部" value="all" />
<el-option label="等待中" value="PENDING" />
<el-option label="處理中" value="PROCESSING" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="失敗" value="FAILED" />
<el-option label="重試中" value="RETRY" />
</el-select>
</div>
<div class="filter-group">
<label>檔案搜尋:</label>
<el-input
v-model="filters.search"
placeholder="請輸入檔案名稱"
style="width: 200px"
clearable
@input="handleSearchChange"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="filter-actions">
<el-button @click="refreshJobs" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button @click="clearFilters">
<el-icon><Close /></el-icon>
清除篩選
</el-button>
</div>
</div>
<!-- 統計資訊 -->
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">總計:</span>
<span class="stat-value">{{ jobStats.total }}</span>
</div>
<div class="stat-item">
<span class="stat-label">等待:</span>
<span class="stat-value stat-pending">{{ jobStats.pending }}</span>
</div>
<div class="stat-item">
<span class="stat-label">處理:</span>
<span class="stat-value stat-processing">{{ jobStats.processing }}</span>
</div>
<div class="stat-item">
<span class="stat-label">完成:</span>
<span class="stat-value stat-completed">{{ jobStats.completed }}</span>
</div>
<div class="stat-item">
<span class="stat-label">失敗:</span>
<span class="stat-value stat-failed">{{ jobStats.failed }}</span>
</div>
</div>
</div>
</div>
<!-- 任務列表 -->
<div class="content-card">
<div class="card-body">
<!-- 載入狀態 -->
<div v-if="loading && jobs.length === 0" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<!-- 空狀態 -->
<div v-else-if="filteredJobs.length === 0" class="empty-state">
<el-icon class="empty-icon">
<Document v-if="jobs.length === 0" />
<Search v-else />
</el-icon>
<div class="empty-title">
{{ jobs.length === 0 ? '暫無任務記錄' : '未找到符合條件的任務' }}
</div>
<div class="empty-description">
{{ jobs.length === 0
? '開始上傳您的第一個檔案進行翻譯吧!'
: '請嘗試調整篩選條件或搜尋關鍵字' }}
</div>
<el-button v-if="jobs.length === 0" type="primary" @click="$router.push('/upload')">
立即上傳
</el-button>
</div>
<!-- 任務列表 -->
<div v-else class="jobs-grid">
<div
v-for="job in filteredJobs"
:key="job.job_uuid"
class="job-card"
@click="viewJobDetail(job.job_uuid)"
>
<!-- 任務標題 -->
<div class="job-header">
<div class="job-title-section">
<div class="file-icon" :class="getFileExtension(job.original_filename)">
{{ getFileExtension(job.original_filename).toUpperCase() }}
</div>
<div class="job-title-info">
<div class="job-title" :title="job.original_filename">
{{ job.original_filename }}
</div>
<div class="job-meta">
<span class="file-size">{{ formatFileSize(job.file_size) }}</span>
<span class="upload-time">{{ formatTime(job.created_at) }}</span>
</div>
</div>
</div>
<div class="job-actions" @click.stop>
<el-dropdown
trigger="click"
@command="handleJobAction($event, job)"
placement="bottom-end"
>
<el-button type="text" size="small">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>
查看詳情
</el-dropdown-item>
<el-dropdown-item
v-if="job.status === 'COMPLETED'"
command="download"
>
<el-icon><Download /></el-icon>
下載檔案
</el-dropdown-item>
<el-dropdown-item
v-if="job.status === 'FAILED'"
command="retry"
>
<el-icon><RefreshRight /></el-icon>
重新翻譯
</el-dropdown-item>
<el-dropdown-item
v-if="['PENDING', 'PROCESSING'].includes(job.status)"
command="cancel"
>
<el-icon><CircleClose /></el-icon>
取消任務
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>
刪除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 翻譯語言 -->
<div class="job-languages">
<div class="language-info">
<span class="language-label">來源:</span>
<span class="language-tag source">
{{ getLanguageText(job.source_language) }}
</span>
</div>
<div class="language-arrow">
<el-icon><ArrowRight /></el-icon>
</div>
<div class="language-info">
<span class="language-label">目標:</span>
<div class="language-tags">
<span
v-for="lang in job.target_languages"
:key="lang"
class="language-tag target"
>
{{ getLanguageText(lang) }}
</span>
</div>
</div>
</div>
<!-- 任務狀態 -->
<div class="job-status-section">
<div class="status-info">
<div class="status-badge" :class="job.status.toLowerCase()">
<el-icon>
<component :is="getStatusIcon(job.status)" />
</el-icon>
<span>{{ getStatusText(job.status) }}</span>
</div>
<div v-if="job.retry_count > 0" class="retry-count">
重試 {{ job.retry_count }}
</div>
</div>
<!-- 進度條 -->
<div v-if="job.status === 'PROCESSING' && job.progress > 0" class="job-progress">
<div class="progress-info">
<span>翻譯進度</span>
<span>{{ Math.round(job.progress) }}%</span>
</div>
<el-progress
:percentage="job.progress"
:stroke-width="6"
:show-text="false"
status="success"
/>
</div>
<!-- 錯誤訊息 -->
<div v-if="job.status === 'FAILED' && job.error_message" class="error-message">
<el-icon><WarningFilled /></el-icon>
<span>{{ job.error_message }}</span>
</div>
</div>
<!-- 任務資訊 -->
<div class="job-footer">
<div class="job-info-grid">
<div class="info-item" v-if="job.processing_started_at">
<span class="info-label">開始:</span>
<span class="info-value">{{ formatTime(job.processing_started_at) }}</span>
</div>
<div class="info-item" v-if="job.completed_at">
<span class="info-label">完成:</span>
<span class="info-value">{{ formatTime(job.completed_at) }}</span>
</div>
<div class="info-item" v-if="job.total_cost > 0">
<span class="info-label">成本:</span>
<span class="info-value">${{ job.total_cost.toFixed(4) }}</span>
</div>
<div class="info-item" v-if="job.total_tokens > 0">
<span class="info-label">Token:</span>
<span class="info-value">{{ job.total_tokens.toLocaleString() }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 分頁 -->
<div class="pagination-section" v-if="pagination.pages > 1">
<el-pagination
v-model:current-page="pagination.page"
:page-size="pagination.per_page"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { debounce } from 'lodash-es'
import {
Upload, Search, Refresh, Close, Document, More, View, Download,
RefreshRight, CircleClose, Delete, ArrowRight, Clock, Loading,
SuccessFilled, CircleCloseFilled, WarningFilled, InfoFilled
} from '@element-plus/icons-vue'
import { initWebSocket } from '@/utils/websocket'
// Router 和 Store
const router = useRouter()
const jobsStore = useJobsStore()
// Debug: 檢查 store 方法
console.log('[DEBUG] JobsStore methods:', Object.getOwnPropertyNames(jobsStore))
console.log('[DEBUG] subscribeToJobUpdates exists:', typeof jobsStore.subscribeToJobUpdates)
console.log('[DEBUG] subscribeToJobUpdates is function:', typeof jobsStore.subscribeToJobUpdates === 'function')
// 響應式數據
const loading = ref(false)
const filters = ref({
status: 'all',
search: ''
})
// 計算屬性
const jobs = computed(() => jobsStore.jobs)
const pagination = computed(() => jobsStore.pagination)
const jobStats = computed(() => jobsStore.jobStats)
const filteredJobs = computed(() => {
let filtered = jobs.value
// 狀態篩選
if (filters.value.status !== 'all') {
filtered = filtered.filter(job => job.status === filters.value.status)
}
// 搜尋篩選
if (filters.value.search.trim()) {
const searchTerm = filters.value.search.toLowerCase().trim()
filtered = filtered.filter(job =>
job.original_filename.toLowerCase().includes(searchTerm)
)
}
return filtered
})
// 語言映射
const languageMap = {
'auto': '自動偵測',
'zh-TW': '繁中',
'zh-CN': '簡中',
'en': '英文',
'ja': '日文',
'ko': '韓文',
'vi': '越文',
'th': '泰文',
'id': '印尼文',
'ms': '馬來文'
}
// 方法
const refreshJobs = async () => {
loading.value = true
try {
await jobsStore.fetchJobs({
page: pagination.value.page,
per_page: pagination.value.per_page,
status: filters.value.status === 'all' ? undefined : filters.value.status
})
} catch (error) {
console.error('刷新任務列表失敗:', error)
} finally {
loading.value = false
}
}
const clearFilters = () => {
filters.value.status = 'all'
filters.value.search = ''
handleFilterChange()
}
const handleFilterChange = debounce(() => {
refreshJobs()
}, 300)
const handleSearchChange = debounce(() => {
// 搜尋是前端過濾,不需要重新請求 API
}, 300)
const handlePageChange = (page) => {
jobsStore.pagination.page = page
refreshJobs()
}
const handleSizeChange = (size) => {
jobsStore.pagination.per_page = size
jobsStore.pagination.page = 1
refreshJobs()
}
const viewJobDetail = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const handleJobAction = async (action, job) => {
switch (action) {
case 'view':
viewJobDetail(job.job_uuid)
break
case 'download':
try {
if (job.target_languages.length === 1) {
// 單一語言直接下載
const ext = getFileExtension(job.original_filename)
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_${job.target_languages[0]}_translated.${ext}`
await jobsStore.downloadFile(job.job_uuid, job.target_languages[0], filename)
} else {
// 多語言打包下載
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_translated.zip`
await jobsStore.downloadAllFiles(job.job_uuid, filename)
}
} catch (error) {
console.error('下載失敗:', error)
}
break
case 'retry':
try {
await ElMessageBox.confirm('確定要重新翻譯此檔案嗎?', '確認重試', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
await jobsStore.retryJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('重試任務失敗:', error)
}
}
break
case 'cancel':
try {
await ElMessageBox.confirm('確定要取消此任務嗎?', '確認取消', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
await jobsStore.cancelJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('取消任務失敗:', error)
}
}
break
case 'delete':
try {
await ElMessageBox.confirm('確定要刪除此任務嗎?此操作無法撤銷。', '確認刪除', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning',
dangerouslyUseHTMLString: true,
message: `<strong>檔案:</strong> ${job.original_filename}<br/><strong>注意:</strong> 刪除後無法恢復`
})
await jobsStore.deleteJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('刪除任務失敗:', error)
}
}
break
}
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const formatTime = (timestamp) => {
if (!timestamp) return ''
const now = new Date()
const time = new Date(timestamp)
const diff = now - time
if (diff < 60000) return '剛剛'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分鐘前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小時前`
if (diff < 2592000000) return `${Math.floor(diff / 86400000)} 天前`
return time.toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getLanguageText = (langCode) => {
return languageMap[langCode] || langCode
}
const getStatusText = (status) => {
const statusMap = {
'PENDING': '等待中',
'PROCESSING': '處理中',
'COMPLETED': '已完成',
'FAILED': '失敗',
'RETRY': '重試中'
}
return statusMap[status] || status
}
const getStatusIcon = (status) => {
const iconMap = {
'PENDING': 'Clock',
'PROCESSING': 'Loading',
'COMPLETED': 'SuccessFilled',
'FAILED': 'CircleCloseFilled',
'RETRY': 'RefreshRight'
}
return iconMap[status] || 'InfoFilled'
}
// 生命週期
onMounted(async () => {
// 暫時禁用 WebSocket 避免連接錯誤
// initWebSocket()
// 載入任務列表
await refreshJobs()
// 為所有處理中的任務訂閱狀態更新
const processingJobs = jobs.value.filter(job =>
['PENDING', 'PROCESSING', 'RETRY'].includes(job.status)
)
processingJobs.forEach(job => {
console.log(`[DEBUG] Attempting to subscribe to job: ${job.job_uuid}`)
if (typeof jobsStore.subscribeToJobUpdates === 'function') {
jobsStore.subscribeToJobUpdates(job.job_uuid)
} else {
console.error('[ERROR] subscribeToJobUpdates is not a function:', typeof jobsStore.subscribeToJobUpdates)
}
})
})
// 監聽任務列表變化,自動訂閱新的處理中任務
watch(jobs, (newJobs, oldJobs) => {
const oldUuids = oldJobs?.map(job => job.job_uuid) || []
const newProcessingJobs = newJobs.filter(job =>
['PENDING', 'PROCESSING', 'RETRY'].includes(job.status) &&
!oldUuids.includes(job.job_uuid)
)
newProcessingJobs.forEach(job => {
console.log(`[DEBUG] Attempting to subscribe to new job: ${job.job_uuid}`)
if (typeof jobsStore.subscribeToJobUpdates === 'function') {
jobsStore.subscribeToJobUpdates(job.job_uuid)
} else {
console.error('[ERROR] subscribeToJobUpdates is not a function in watch:', typeof jobsStore.subscribeToJobUpdates)
}
})
}, { deep: true })
// 組件卸載時清理輪詢
onUnmounted(() => {
console.log('[DEBUG] 組件卸載,停止所有輪詢')
jobsStore.stopAllPolling()
})
</script>
<style lang="scss" scoped>
.job-list-view {
.filters-section {
.filters-row {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 16px;
flex-wrap: wrap;
.filter-group {
display: flex;
align-items: center;
gap: 8px;
label {
font-size: 14px;
color: var(--el-text-color-regular);
white-space: nowrap;
}
}
.filter-actions {
margin-left: auto;
display: flex;
gap: 8px;
@media (max-width: 768px) {
margin-left: 0;
width: 100%;
}
}
}
.stats-row {
display: flex;
gap: 24px;
padding: 12px 16px;
background-color: var(--el-fill-color-lighter);
border-radius: 6px;
flex-wrap: wrap;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
.stat-label {
color: var(--el-text-color-regular);
}
.stat-value {
font-weight: 600;
color: var(--el-text-color-primary);
&.stat-pending { color: var(--el-color-info); }
&.stat-processing { color: var(--el-color-primary); }
&.stat-completed { color: var(--el-color-success); }
&.stat-failed { color: var(--el-color-danger); }
}
}
}
}
.jobs-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
.job-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 16px;
background: var(--el-bg-color);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.job-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
.job-title-section {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
min-width: 0;
.file-icon {
width: 36px;
height: 36px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
flex-shrink: 0;
&.docx, &.doc { background-color: #2b579a; }
&.pptx, &.ppt { background-color: #d24726; }
&.xlsx, &.xls { background-color: #207245; }
&.pdf { background-color: #ff0000; }
}
.job-title-info {
flex: 1;
min-width: 0;
.job-title {
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
}
.job-languages {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 13px;
.language-info {
display: flex;
align-items: center;
gap: 4px;
.language-label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
}
.language-arrow {
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.language-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
&.source {
background-color: var(--el-color-info-light-8);
color: var(--el-color-info);
border: 1px solid var(--el-color-info-light-5);
}
&.target {
background-color: var(--el-color-primary-light-8);
color: var(--el-color-primary);
border: 1px solid var(--el-color-primary-light-5);
margin-right: 4px;
&:last-child {
margin-right: 0;
}
}
}
.language-tags {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
}
.job-status-section {
margin-bottom: 12px;
.status-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.status-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.pending {
background-color: var(--el-color-info-light-9);
color: var(--el-color-info);
border: 1px solid var(--el-color-info-light-5);
}
&.processing {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
border: 1px solid var(--el-color-primary-light-5);
}
&.completed {
background-color: var(--el-color-success-light-9);
color: var(--el-color-success);
border: 1px solid var(--el-color-success-light-5);
}
&.failed {
background-color: var(--el-color-danger-light-9);
color: var(--el-color-danger);
border: 1px solid var(--el-color-danger-light-5);
}
&.retry {
background-color: var(--el-color-warning-light-9);
color: var(--el-color-warning);
border: 1px solid var(--el-color-warning-light-5);
}
}
.retry-count {
font-size: 11px;
color: var(--el-text-color-secondary);
}
}
.job-progress {
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-size: 12px;
color: var(--el-text-color-regular);
}
}
.error-message {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background-color: var(--el-color-danger-light-9);
border: 1px solid var(--el-color-danger-light-5);
border-radius: 4px;
color: var(--el-color-danger);
font-size: 12px;
margin-top: 6px;
}
}
.job-footer {
.job-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
font-size: 12px;
.info-item {
display: flex;
justify-content: space-between;
.info-label {
color: var(--el-text-color-secondary);
}
.info-value {
color: var(--el-text-color-primary);
font-weight: 500;
}
}
}
}
}
}
.pagination-section {
margin-top: 24px;
display: flex;
justify-content: center;
}
}
.loading-state {
padding: 24px 0;
}
</style>

View File

@@ -0,0 +1,350 @@
<template>
<div class="login-layout">
<div class="login-container">
<div class="login-header">
<div class="login-logo">
<img src="/panjit-logo.png" alt="PANJIT Logo" />
</div>
<h1 class="login-title">PANJIT 翻譯系統</h1>
<p class="login-subtitle">企業級文件批量翻譯管理系統</p>
</div>
<div class="login-body">
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
@keyup.enter="handleLogin"
label-position="top"
size="large"
>
<el-form-item label="AD 帳號" prop="username">
<el-input
v-model="loginForm.username"
placeholder="請輸入您的 AD 帳號"
:prefix-icon="User"
clearable
:disabled="loading"
/>
</el-form-item>
<el-form-item label="密碼" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="請輸入密碼"
:prefix-icon="Lock"
show-password
clearable
:disabled="loading"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="rememberMe" :disabled="loading">
記住登入狀態
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
size="large"
:loading="loading"
:disabled="!loginForm.username || !loginForm.password"
@click="handleLogin"
style="width: 100%; background-color: #3498db; border-color: #3498db; color: white;"
@mouseenter="$event.currentTarget.style.backgroundColor='#2980b9'"
@mouseleave="$event.currentTarget.style.backgroundColor='#3498db'"
>
{{ loading ? '登入中...' : '登入' }}
</el-button>
</el-form-item>
</el-form>
<!-- 錯誤訊息顯示 -->
<div v-if="errorMessage" class="error-message">
<el-alert
:title="errorMessage"
type="error"
:closable="false"
show-icon
/>
</div>
<!-- 登入提示 -->
<div class="login-tips">
<el-alert
title="登入說明"
type="info"
:closable="false"
show-icon
>
<p>請使用您的 PANJIT AD 域帳號登入系統</p>
<p>如果您忘記密碼或遇到登入問題請聯繫 IT 部門協助</p>
</el-alert>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { Document, User, Lock } from '@element-plus/icons-vue'
// Router 和 Store
const router = useRouter()
const authStore = useAuthStore()
// 響應式數據
const loginFormRef = ref()
const loading = ref(false)
const rememberMe = ref(false)
const errorMessage = ref('')
// 登入表單數據
const loginForm = reactive({
username: '',
password: ''
})
// 表單驗證規則
const loginRules = {
username: [
{ required: true, message: '請輸入 AD 帳號', trigger: 'blur' },
{ min: 3, message: '帳號長度不能少於3個字元', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9._@-]+$/,
message: '帳號格式不正確,只能包含字母、數字、點、下劃線、@符號和連字符',
trigger: 'blur'
}
],
password: [
{ required: true, message: '請輸入密碼', trigger: 'blur' },
{ min: 1, message: '密碼不能為空', trigger: 'blur' }
]
}
// 方法
const handleLogin = async () => {
try {
// 清除之前的錯誤訊息
errorMessage.value = ''
// 驗證表單
const valid = await loginFormRef.value.validate()
if (!valid) {
return
}
loading.value = true
// 準備登入資料
const credentials = {
username: loginForm.username.trim(),
password: loginForm.password
}
// 如果帳號不包含 @,自動添加域名
if (!credentials.username.includes('@')) {
credentials.username = `${credentials.username}@panjit.com.tw`
}
// 執行登入
await authStore.login(credentials)
// 如果選擇記住登入狀態,可以在這裡處理
if (rememberMe.value) {
localStorage.setItem('rememberLogin', 'true')
}
// 登入成功,跳轉到首頁
router.push('/')
} catch (error) {
console.error('登入失敗:', error)
// 處理不同的錯誤類型
if (error.response?.status === 401) {
errorMessage.value = '帳號或密碼錯誤,請重新輸入'
} else if (error.response?.status === 403) {
errorMessage.value = '您的帳號沒有權限存取此系統'
} else if (error.response?.status === 500) {
errorMessage.value = '伺服器錯誤,請稍後再試'
} else if (error.message?.includes('LDAP')) {
errorMessage.value = 'AD 伺服器連接失敗,請聯繫 IT 部門'
} else if (error.message?.includes('network')) {
errorMessage.value = '網路連接失敗,請檢查網路設定'
} else {
errorMessage.value = error.message || '登入失敗,請重試'
}
// 清空密碼欄位
loginForm.password = ''
// 3秒後自動清除錯誤訊息
setTimeout(() => {
errorMessage.value = ''
}, 5000)
} finally {
loading.value = false
}
}
const clearError = () => {
errorMessage.value = ''
}
// 生命週期
onMounted(() => {
// 如果已經登入,直接跳轉
if (authStore.isAuthenticated) {
router.push('/')
return
}
// 檢查是否記住了登入狀態
const rememberLogin = localStorage.getItem('rememberLogin')
if (rememberLogin === 'true') {
rememberMe.value = true
}
// 僅在頁面載入時檢查認證狀態不調用API
const authUser = localStorage.getItem('auth_user')
const authAuthenticated = localStorage.getItem('auth_authenticated')
if (authUser && authAuthenticated === 'true') {
// 如果已經有認證資訊,直接跳轉
router.push('/')
}
// 監聽表單變化,清除錯誤訊息
const unwatchForm = watch([() => loginForm.username, () => loginForm.password], () => {
if (errorMessage.value) {
clearError()
}
})
// 頁面卸載時取消監聽
onUnmounted(() => {
unwatchForm()
})
})
</script>
<style lang="scss" scoped>
.error-message {
margin-top: 16px;
}
.login-tips {
margin-top: 24px;
:deep(.el-alert__content) {
p {
margin: 4px 0;
font-size: 13px;
line-height: 1.4;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
}
// 響應式設計
@media (max-width: 480px) {
.login-layout {
padding: 16px;
.login-container {
max-width: 100%;
.login-header {
padding: 24px;
.login-logo {
width: 120px;
height: 60px;
margin-bottom: 16px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.login-title {
font-size: 20px;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 13px;
}
}
.login-body {
padding: 24px;
}
.login-footer {
padding: 16px 24px;
font-size: 12px;
}
}
}
}
// 加載狀態下的樣式
.loading {
pointer-events: none;
opacity: 0.7;
}
// 動畫效果
.login-container {
animation: slideInUp 0.5s ease-out;
}
@keyframes slideInUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
// Element Plus 組件樣式覆蓋
:deep(.el-form-item__label) {
color: var(--el-text-color-primary);
font-weight: 500;
}
:deep(.el-input__inner) {
border-radius: 6px;
}
:deep(.el-button) {
border-radius: 6px;
font-weight: 500;
}
:deep(.el-checkbox__label) {
font-size: 14px;
color: var(--el-text-color-regular);
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<div class="not-found-view">
<div class="not-found-container">
<div class="not-found-illustration">
<div class="error-code">404</div>
<div class="error-icon">
<el-icon><QuestionFilled /></el-icon>
</div>
</div>
<div class="not-found-content">
<h1 class="error-title">頁面不存在</h1>
<p class="error-description">
抱歉您訪問的頁面不存在或已被移除
</p>
<div class="error-actions">
<el-button type="primary" size="large" @click="goHome">
<el-icon><House /></el-icon>
回到首頁
</el-button>
<el-button size="large" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回上頁
</el-button>
</div>
</div>
<div class="helpful-links">
<h3>您可能在尋找</h3>
<div class="links-grid">
<router-link to="/upload" class="link-card">
<div class="link-icon">
<el-icon><Upload /></el-icon>
</div>
<div class="link-content">
<div class="link-title">檔案上傳</div>
<div class="link-desc">上傳新的檔案進行翻譯</div>
</div>
</router-link>
<router-link to="/jobs" class="link-card">
<div class="link-icon">
<el-icon><List /></el-icon>
</div>
<div class="link-content">
<div class="link-title">任務列表</div>
<div class="link-desc">查看您的翻譯任務</div>
</div>
</router-link>
<router-link to="/history" class="link-card">
<div class="link-icon">
<el-icon><Clock /></el-icon>
</div>
<div class="link-content">
<div class="link-title">歷史記錄</div>
<div class="link-desc">瀏覽過往的翻譯記錄</div>
</div>
</router-link>
<router-link to="/profile" class="link-card">
<div class="link-icon">
<el-icon><User /></el-icon>
</div>
<div class="link-content">
<div class="link-title">個人設定</div>
<div class="link-desc">管理您的個人資料</div>
</div>
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import {
QuestionFilled, House, ArrowLeft, Upload, List, Clock, User
} from '@element-plus/icons-vue'
const router = useRouter()
const goHome = () => {
router.push('/')
}
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
</script>
<style lang="scss" scoped>
.not-found-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
.not-found-container {
max-width: 800px;
width: 100%;
text-align: center;
.not-found-illustration {
position: relative;
margin-bottom: 40px;
.error-code {
font-size: 120px;
font-weight: bold;
color: var(--el-color-primary);
line-height: 1;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
@media (max-width: 480px) {
font-size: 80px;
}
}
.error-icon {
font-size: 60px;
color: var(--el-color-info);
opacity: 0.6;
@media (max-width: 480px) {
font-size: 40px;
}
}
}
.not-found-content {
margin-bottom: 50px;
.error-title {
font-size: 32px;
font-weight: bold;
color: var(--el-text-color-primary);
margin: 0 0 16px 0;
@media (max-width: 480px) {
font-size: 24px;
}
}
.error-description {
font-size: 16px;
color: var(--el-text-color-regular);
line-height: 1.6;
margin: 0 0 32px 0;
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
.error-actions {
display: flex;
justify-content: center;
gap: 16px;
@media (max-width: 480px) {
flex-direction: column;
align-items: center;
}
}
}
.helpful-links {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
h3 {
font-size: 18px;
color: var(--el-text-color-primary);
margin: 0 0 24px 0;
}
.links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
.link-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: 12px;
text-decoration: none;
transition: all 0.3s ease;
&:hover {
background: var(--el-color-primary-light-9);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.2);
}
.link-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--el-color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.link-content {
text-align: left;
.link-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.link-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
}
}
}
}
}
}
// 動畫效果
.not-found-container {
animation: fadeInUp 0.8s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.error-code {
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
</style>

View File

@@ -0,0 +1,562 @@
<template>
<div class="profile-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">個人設定</h1>
</div>
<!-- 用戶資訊 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">基本資訊</h3>
</div>
<div class="card-body">
<div class="user-profile">
<div class="avatar-section">
<div class="user-avatar">
<div class="avatar-circle">
{{ userInitials }}
</div>
</div>
<div class="user-basic-info">
<h3 class="user-name">{{ authStore.userName }}</h3>
<p class="user-email">{{ authStore.userEmail }}</p>
<el-tag v-if="authStore.isAdmin" type="warning" size="small">
管理員
</el-tag>
</div>
</div>
<div class="user-details">
<div class="detail-row">
<div class="detail-item">
<div class="detail-label">AD 帳號</div>
<div class="detail-value">{{ authStore.user?.username }}</div>
</div>
<div class="detail-item">
<div class="detail-label">部門</div>
<div class="detail-value">{{ authStore.department || '未設定' }}</div>
</div>
</div>
<div class="detail-row">
<div class="detail-item">
<div class="detail-label">最後登入</div>
<div class="detail-value">{{ formatTime(authStore.user?.last_login) }}</div>
</div>
<div class="detail-item">
<div class="detail-label">權限等級</div>
<div class="detail-value">{{ authStore.isAdmin ? '管理員' : '一般使用者' }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 使用統計 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">使用統計</h3>
<div class="card-actions">
<el-button type="text" @click="refreshStats" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<div class="card-body">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">
<el-icon><Files /></el-icon>
</div>
<div class="stat-value">{{ userStats.totalJobs }}</div>
<div class="stat-label">總任務數</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<el-icon><SuccessFilled /></el-icon>
</div>
<div class="stat-value">{{ userStats.completedJobs }}</div>
<div class="stat-label">已完成</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">
<el-icon><Money /></el-icon>
</div>
<div class="stat-value">${{ userStats.totalCost.toFixed(4) }}</div>
<div class="stat-label">總成本</div>
</div>
<div class="stat-card">
<div class="stat-icon info">
<el-icon><Clock /></el-icon>
</div>
<div class="stat-value">{{ userStats.avgProcessingTime }}</div>
<div class="stat-label">平均處理時間</div>
</div>
</div>
</div>
</div>
<!-- 偏好設定 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">偏好設定</h3>
</div>
<div class="card-body">
<el-form :model="preferences" label-width="120px" size="default">
<el-form-item label="預設來源語言">
<el-select v-model="preferences.defaultSourceLang" style="width: 200px">
<el-option label="自動偵測" value="auto" />
<el-option label="繁體中文" value="zh-TW" />
<el-option label="簡體中文" value="zh-CN" />
<el-option label="英文" value="en" />
<el-option label="日文" value="ja" />
<el-option label="韓文" value="ko" />
<el-option label="越南文" value="vi" />
</el-select>
</el-form-item>
<el-form-item label="預設目標語言">
<el-select
v-model="preferences.defaultTargetLangs"
multiple
style="width: 300px"
placeholder="請選擇常用的目標語言"
>
<el-option label="英文" value="en" />
<el-option label="越南文" value="vi" />
<el-option label="繁體中文" value="zh-TW" />
<el-option label="簡體中文" value="zh-CN" />
<el-option label="日文" value="ja" />
<el-option label="韓文" value="ko" />
</el-select>
</el-form-item>
<el-form-item label="通知設定">
<el-checkbox-group v-model="preferences.notifications">
<el-checkbox label="email" name="notifications">
翻譯完成時發送郵件通知
</el-checkbox>
<el-checkbox label="browser" name="notifications">
瀏覽器桌面通知
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="介面語言">
<el-select v-model="preferences.interfaceLang" style="width: 150px">
<el-option label="繁體中文" value="zh-TW" />
<el-option label="English" value="en" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="savePreferences" :loading="saving">
儲存設定
</el-button>
<el-button @click="resetPreferences">
重置為預設值
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 快速操作 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">快速操作</h3>
</div>
<div class="card-body">
<div class="quick-actions">
<el-button @click="$router.push('/upload')">
<el-icon><Upload /></el-icon>
上傳新檔案
</el-button>
<el-button @click="$router.push('/jobs')">
<el-icon><List /></el-icon>
查看我的任務
</el-button>
<el-button @click="$router.push('/history')">
<el-icon><Clock /></el-icon>
瀏覽歷史記錄
</el-button>
<el-button @click="downloadUserData" :loading="downloading">
<el-icon><Download /></el-icon>
匯出我的資料
</el-button>
</div>
</div>
</div>
<!-- 帳號安全 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">帳號安全</h3>
</div>
<div class="card-body">
<div class="security-info">
<div class="security-item">
<div class="security-icon">
<el-icon><Key /></el-icon>
</div>
<div class="security-content">
<div class="security-title">密碼管理</div>
<div class="security-description">
本系統使用公司 AD 帳號認證如需變更密碼請聯繫 IT 部門
</div>
</div>
</div>
<div class="security-item">
<div class="security-icon">
<el-icon><Lock /></el-icon>
</div>
<div class="security-content">
<div class="security-title">登入記錄</div>
<div class="security-description">
最後登入時間: {{ formatTime(authStore.user?.last_login) }}
</div>
<el-button type="text" @click="viewLoginHistory">
查看詳細記錄
</el-button>
</div>
</div>
<div class="security-item">
<div class="security-icon">
<el-icon><UserFilled /></el-icon>
</div>
<div class="security-content">
<div class="security-title">權限說明</div>
<div class="security-description">
{{ authStore.isAdmin
? '您擁有系統管理員權限,可以查看所有用戶的任務和系統統計'
: '您為一般使用者,只能查看和管理自己的翻譯任務' }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage } from 'element-plus'
import {
Refresh, Files, SuccessFilled, Money, Clock, Upload, List,
Download, Key, Lock, UserFilled
} from '@element-plus/icons-vue'
// Router 和 Stores
const router = useRouter()
const authStore = useAuthStore()
const jobsStore = useJobsStore()
// 響應式數據
const loading = ref(false)
const saving = ref(false)
const downloading = ref(false)
// 偏好設定
const preferences = reactive({
defaultSourceLang: 'auto',
defaultTargetLangs: ['en'],
notifications: ['email'],
interfaceLang: 'zh-TW'
})
// 計算屬性
const userInitials = computed(() => {
const name = authStore.userName || authStore.user?.username || 'U'
return name.charAt(0).toUpperCase()
})
const userStats = computed(() => {
const jobs = jobsStore.jobs
const completedJobs = jobs.filter(job => job.status === 'COMPLETED')
const totalCost = jobs.reduce((sum, job) => sum + (job.total_cost || 0), 0)
// 計算平均處理時間
const processingTimes = completedJobs
.filter(job => job.processing_started_at && job.completed_at)
.map(job => {
const start = new Date(job.processing_started_at)
const end = new Date(job.completed_at)
return end - start
})
let avgProcessingTime = '無資料'
if (processingTimes.length > 0) {
const avgMs = processingTimes.reduce((sum, time) => sum + time, 0) / processingTimes.length
const minutes = Math.floor(avgMs / 60000)
const seconds = Math.floor((avgMs % 60000) / 1000)
avgProcessingTime = `${minutes}${seconds}`
}
return {
totalJobs: jobs.length,
completedJobs: completedJobs.length,
totalCost,
avgProcessingTime
}
})
// 方法
const refreshStats = async () => {
loading.value = true
try {
await jobsStore.fetchJobs({ per_page: 100 })
ElMessage.success('統計資料已刷新')
} catch (error) {
console.error('刷新統計失敗:', error)
ElMessage.error('刷新統計失敗')
} finally {
loading.value = false
}
}
const savePreferences = async () => {
saving.value = true
try {
// 儲存到本地存儲
localStorage.setItem('userPreferences', JSON.stringify(preferences))
// 同時更新翻譯設定的預設值
localStorage.setItem('translation_settings', JSON.stringify({
sourceLanguage: preferences.defaultSourceLang,
targetLanguages: preferences.defaultTargetLangs
}))
ElMessage.success('設定已儲存')
} catch (error) {
console.error('儲存設定失敗:', error)
ElMessage.error('儲存設定失敗')
} finally {
saving.value = false
}
}
const resetPreferences = () => {
Object.assign(preferences, {
defaultSourceLang: 'auto',
defaultTargetLangs: ['en'],
notifications: ['email'],
interfaceLang: 'zh-TW'
})
ElMessage.info('設定已重置為預設值')
}
const downloadUserData = async () => {
downloading.value = true
try {
const userData = {
userInfo: {
username: authStore.user?.username,
displayName: authStore.userName,
email: authStore.userEmail,
department: authStore.department,
isAdmin: authStore.isAdmin
},
jobs: jobsStore.jobs,
statistics: userStats.value,
preferences: preferences,
exportTime: new Date().toISOString()
}
const dataStr = JSON.stringify(userData, null, 2)
const blob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `我的翻譯資料_${new Date().toISOString().slice(0, 10)}.json`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('資料匯出完成')
} catch (error) {
console.error('匯出資料失敗:', error)
ElMessage.error('匯出資料失敗')
} finally {
downloading.value = false
}
}
const viewLoginHistory = () => {
ElMessage.info('登入記錄功能開發中,敬請期待')
}
const formatTime = (timestamp) => {
if (!timestamp) return '未知'
const now = new Date()
const time = new Date(timestamp)
const diff = now - time
if (diff < 60000) return '剛剛'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分鐘前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小時前`
if (diff < 2592000000) return `${Math.floor(diff / 86400000)} 天前`
return time.toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 生命週期
onMounted(async () => {
// 載入用戶偏好設定
try {
const savedPreferences = localStorage.getItem('userPreferences')
if (savedPreferences) {
Object.assign(preferences, JSON.parse(savedPreferences))
}
} catch (error) {
console.error('載入偏好設定失敗:', error)
}
// 載入統計資料
await refreshStats()
})
</script>
<style lang="scss" scoped>
.profile-view {
.user-profile {
.avatar-section {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
.user-avatar {
.avatar-circle {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(45deg, var(--el-color-primary), var(--el-color-primary-light-3));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
font-weight: bold;
}
}
.user-basic-info {
.user-name {
margin: 0 0 8px 0;
color: var(--el-text-color-primary);
font-size: 20px;
}
.user-email {
margin: 0 0 8px 0;
color: var(--el-text-color-secondary);
font-size: 14px;
}
}
}
.user-details {
.detail-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
margin-bottom: 16px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 16px;
}
.detail-item {
.detail-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.detail-value {
font-size: 14px;
color: var(--el-text-color-primary);
font-weight: 500;
}
}
}
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.security-info {
.security-item {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.security-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.security-content {
flex: 1;
.security-title {
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.security-description {
color: var(--el-text-color-regular);
line-height: 1.5;
margin-bottom: 8px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,865 @@
<template>
<div class="upload-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">檔案上傳</h1>
<div class="page-actions">
<el-button @click="$router.push('/jobs')">
<el-icon><List /></el-icon>
查看任務列表
</el-button>
</div>
</div>
<div class="upload-content">
<!-- 上傳區域 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">選擇要翻譯的檔案</h3>
<div class="card-subtitle">
支援 DOCXDOCPPTXXLSXXLSPDF 格式單檔最大 25MB
</div>
</div>
<div class="card-body">
<!-- 檔案上傳器 -->
<el-upload
ref="uploadRef"
class="upload-dragger"
:class="{ disabled: uploading }"
drag
:multiple="true"
:show-file-list="false"
:before-upload="handleBeforeUpload"
:http-request="() => {}"
:disabled="uploading"
>
<div class="upload-content-inner">
<el-icon class="upload-icon">
<UploadFilled />
</el-icon>
<div class="upload-text">
<div class="upload-title">拖拽檔案至此或點擊選擇檔案</div>
<div class="upload-hint">
支援 .docx, .doc, .pptx, .xlsx, .xls, .pdf 格式
</div>
</div>
</div>
</el-upload>
<!-- 已選擇的檔案列表 -->
<div v-if="selectedFiles.length > 0" class="selected-files">
<div class="files-header">
<h4>已選擇的檔案 ({{ selectedFiles.length }})</h4>
<el-button type="text" @click="clearFiles" :disabled="uploading">
<el-icon><Delete /></el-icon>
清空
</el-button>
</div>
<div class="files-list">
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="file-item"
>
<div class="file-icon">
<div class="file-type" :class="getFileExtension(file.name)">
{{ getFileExtension(file.name).toUpperCase() }}
</div>
</div>
<div class="file-info">
<div class="file-name">{{ file.name }}</div>
<div class="file-details">
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<span class="file-type-text">{{ getFileTypeText(file.name) }}</span>
</div>
</div>
<div class="file-actions">
<el-button
type="text"
size="small"
@click="removeFile(index)"
:disabled="uploading"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 翻譯設定 -->
<div class="content-card" v-if="selectedFiles.length > 0">
<div class="card-header">
<h3 class="card-title">翻譯設定</h3>
</div>
<div class="card-body">
<el-form
ref="translationFormRef"
:model="translationForm"
:rules="translationRules"
label-width="120px"
size="large"
>
<el-form-item label="來源語言" prop="sourceLanguage">
<el-select
v-model="translationForm.sourceLanguage"
placeholder="請選擇來源語言"
style="width: 100%"
:disabled="uploading"
>
<el-option label="自動偵測" value="auto" />
<el-option label="繁體中文" value="zh-TW" />
<el-option label="簡體中文" value="zh-CN" />
<el-option label="英文" value="en" />
<el-option label="日文" value="ja" />
<el-option label="韓文" value="ko" />
<el-option label="越南文" value="vi" />
</el-select>
</el-form-item>
<el-form-item label="目標語言" prop="targetLanguages">
<el-select
v-model="translationForm.targetLanguages"
multiple
placeholder="請選擇目標語言可多選"
style="width: 100%"
:disabled="uploading"
collapse-tags
collapse-tags-tooltip
>
<el-option label="英文" value="en" />
<el-option label="越南文" value="vi" />
<el-option label="繁體中文" value="zh-TW" />
<el-option label="簡體中文" value="zh-CN" />
<el-option label="日文" value="ja" />
<el-option label="韓文" value="ko" />
<el-option label="泰文" value="th" />
<el-option label="印尼文" value="id" />
<el-option label="馬來文" value="ms" />
</el-select>
<div class="form-tip">
<el-icon><InfoFilled /></el-icon>
可以同時選擇多個目標語言,系統會分別生成對應的翻譯檔案
</div>
</el-form-item>
<el-form-item>
<div class="translation-actions">
<el-button
type="primary"
size="large"
:loading="uploading"
:disabled="selectedFiles.length === 0 || translationForm.targetLanguages.length === 0"
@click="startTranslation"
>
<el-icon><Upload /></el-icon>
{{ uploading ? '上傳中...' : `開始翻譯 (${selectedFiles.length} 個檔案)` }}
</el-button>
<el-button size="large" @click="resetForm" :disabled="uploading">
重置
</el-button>
</div>
</el-form-item>
</el-form>
</div>
</div>
<!-- 上傳進度 -->
<div class="content-card" v-if="uploading || uploadResults.length > 0">
<div class="card-header">
<h3 class="card-title">上傳進度</h3>
</div>
<div class="card-body">
<div class="upload-progress">
<!-- 總體進度 -->
<div class="overall-progress" v-if="uploading">
<div class="progress-info">
<span>整體進度: {{ currentFileIndex + 1 }} / {{ selectedFiles.length }}</span>
<span>{{ Math.round(overallProgress) }}%</span>
</div>
<el-progress
:percentage="overallProgress"
:stroke-width="8"
:show-text="false"
status="success"
/>
</div>
<!-- 個別檔案進度 -->
<div class="files-progress">
<div
v-for="(result, index) in uploadResults"
:key="index"
class="file-progress-item"
:class="result.status"
>
<div class="file-info">
<div class="file-icon">
<div class="file-type" :class="getFileExtension(result.filename)">
{{ getFileExtension(result.filename).toUpperCase() }}
</div>
</div>
<div class="file-details">
<div class="file-name">{{ result.filename }}</div>
<div class="file-status">
<el-icon v-if="result.status === 'success'">
<SuccessFilled />
</el-icon>
<el-icon v-else-if="result.status === 'error'">
<CircleCloseFilled />
</el-icon>
<el-icon v-else>
<Loading />
</el-icon>
<span>{{ getUploadStatusText(result.status) }}</span>
</div>
</div>
</div>
<div class="file-progress" v-if="result.status === 'uploading'">
<el-progress
:percentage="result.progress || 0"
:stroke-width="4"
:show-text="false"
/>
</div>
<div class="file-actions" v-if="result.status === 'success'">
<el-button
type="text"
size="small"
@click="viewJob(result.jobUuid)"
>
查看任務
</el-button>
</div>
</div>
</div>
<!-- 完成後的操作 -->
<div class="upload-complete-actions" v-if="!uploading && uploadResults.length > 0">
<el-button type="primary" @click="$router.push('/jobs')">
<el-icon><List /></el-icon>
查看所有任務
</el-button>
<el-button @click="resetUpload">
<el-icon><RefreshRight /></el-icon>
重新上傳
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
List, UploadFilled, Delete, Close, Upload, InfoFilled,
SuccessFilled, CircleCloseFilled, Loading, RefreshRight
} from '@element-plus/icons-vue'
// Router 和 Stores
const router = useRouter()
const jobsStore = useJobsStore()
// 組件引用
const uploadRef = ref()
const translationFormRef = ref()
// 響應式數據
const selectedFiles = ref([])
const uploading = ref(false)
const currentFileIndex = ref(0)
const uploadResults = ref([])
// 表單數據
const translationForm = reactive({
sourceLanguage: 'auto',
targetLanguages: []
})
// 表單驗證規則
const translationRules = {
targetLanguages: [
{ required: true, message: '請至少選擇一個目標語言', trigger: 'change' },
{
type: 'array',
min: 1,
message: '請至少選擇一個目標語言',
trigger: 'change'
}
]
}
// 支援的檔案類型
const supportedTypes = {
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'doc': 'application/msword',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'ppt': 'application/vnd.ms-powerpoint',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls': 'application/vnd.ms-excel',
'pdf': 'application/pdf'
}
// 計算屬性
const overallProgress = computed(() => {
if (uploadResults.value.length === 0) return 0
const totalProgress = uploadResults.value.reduce((sum, result) => {
if (result.status === 'success') return sum + 100
if (result.status === 'error') return sum + 100
return sum + (result.progress || 0)
}, 0)
return (totalProgress / selectedFiles.value.length)
})
// 方法
const handleBeforeUpload = (file) => {
// 檢查檔案類型
const extension = getFileExtension(file.name)
if (!supportedTypes[extension]) {
ElMessage.error(`不支援的檔案類型: ${extension}`)
return false
}
// 檢查檔案大小
const maxSize = 25 * 1024 * 1024 // 25MB
if (file.size > maxSize) {
ElMessage.error(`檔案大小不能超過 25MB當前檔案: ${formatFileSize(file.size)}`)
return false
}
// 檢查是否已存在
const exists = selectedFiles.value.some(f => f.name === file.name)
if (exists) {
ElMessage.warning('檔案已存在於列表中')
return false
}
// 添加到選擇列表
selectedFiles.value.push(file)
ElMessage.success(`已添加檔案: ${file.name}`)
return false // 阻止自動上傳
}
const removeFile = (index) => {
const filename = selectedFiles.value[index].name
selectedFiles.value.splice(index, 1)
ElMessage.info(`已移除檔案: ${filename}`)
}
const clearFiles = async () => {
try {
await ElMessageBox.confirm('確定要清空所有已選檔案嗎?', '確認清空', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
selectedFiles.value = []
ElMessage.success('已清空檔案列表')
} catch (error) {
// 用戶取消
}
}
const startTranslation = async () => {
try {
// 驗證表單
const valid = await translationFormRef.value.validate()
if (!valid) {
return
}
if (selectedFiles.value.length === 0) {
ElMessage.warning('請先選擇要翻譯的檔案')
return
}
// 開始上傳
uploading.value = true
currentFileIndex.value = 0
uploadResults.value = []
// 為每個檔案創建上傳記錄
selectedFiles.value.forEach(file => {
uploadResults.value.push({
filename: file.name,
status: 'waiting',
progress: 0,
jobUuid: null,
error: null
})
})
// 逐個上傳檔案
for (let i = 0; i < selectedFiles.value.length; i++) {
currentFileIndex.value = i
const file = selectedFiles.value[i]
const resultIndex = i
try {
// 更新狀態為上傳中
uploadResults.value[resultIndex].status = 'uploading'
// 創建 FormData
const formData = new FormData()
formData.append('file', file)
formData.append('source_language', translationForm.sourceLanguage)
formData.append('target_languages', JSON.stringify(translationForm.targetLanguages))
// 上傳檔案
const result = await jobsStore.uploadFile(formData, (progress) => {
uploadResults.value[resultIndex].progress = progress
})
// 上傳成功
uploadResults.value[resultIndex].status = 'success'
uploadResults.value[resultIndex].progress = 100
uploadResults.value[resultIndex].jobUuid = result.job_uuid
} catch (error) {
console.error(`檔案 ${file.name} 上傳失敗:`, error)
uploadResults.value[resultIndex].status = 'error'
uploadResults.value[resultIndex].error = error.message || '上傳失敗'
ElMessage.error(`檔案 ${file.name} 上傳失敗: ${error.message || '未知錯誤'}`)
}
}
// 檢查上傳結果
const successCount = uploadResults.value.filter(r => r.status === 'success').length
const failCount = uploadResults.value.filter(r => r.status === 'error').length
if (successCount > 0) {
ElMessage.success(`成功上傳 ${successCount} 個檔案`)
}
if (failCount > 0) {
ElMessage.error(`${failCount} 個檔案上傳失敗`)
}
} catch (error) {
console.error('批量上傳失敗:', error)
ElMessage.error('批量上傳失敗')
} finally {
uploading.value = false
}
}
const resetForm = () => {
selectedFiles.value = []
translationForm.sourceLanguage = 'auto'
translationForm.targetLanguages = []
uploadResults.value = []
translationFormRef.value?.resetFields()
}
const resetUpload = () => {
uploadResults.value = []
currentFileIndex.value = 0
}
const viewJob = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
const getFileTypeText = (filename) => {
const ext = getFileExtension(filename)
const typeMap = {
'docx': 'Word 文件',
'doc': 'Word 文件',
'pptx': 'PowerPoint 簡報',
'ppt': 'PowerPoint 簡報',
'xlsx': 'Excel 試算表',
'xls': 'Excel 試算表',
'pdf': 'PDF 文件'
}
return typeMap[ext] || ext.toUpperCase()
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const getUploadStatusText = (status) => {
const statusMap = {
'waiting': '等待中',
'uploading': '上傳中',
'success': '上傳成功',
'error': '上傳失敗'
}
return statusMap[status] || status
}
// 生命週期
onMounted(() => {
// 載入使用者偏好設定(如果有的話)
const savedSettings = localStorage.getItem('translation_settings')
if (savedSettings) {
try {
const settings = JSON.parse(savedSettings)
translationForm.sourceLanguage = settings.sourceLanguage || 'auto'
translationForm.targetLanguages = settings.targetLanguages || []
} catch (error) {
console.error('載入設定失敗:', error)
}
}
})
// 監聽表單變化,保存設定
watch([() => translationForm.sourceLanguage, () => translationForm.targetLanguages], () => {
const settings = {
sourceLanguage: translationForm.sourceLanguage,
targetLanguages: translationForm.targetLanguages
}
localStorage.setItem('translation_settings', JSON.stringify(settings))
}, { deep: true })
</script>
<style lang="scss" scoped>
.upload-view {
.upload-content {
.content-card {
&:not(:last-child) {
margin-bottom: 24px;
}
.card-subtitle {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
}
}
.upload-dragger {
:deep(.el-upload-dragger) {
border: 2px dashed var(--el-border-color);
border-radius: 8px;
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
&.is-dragover {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-8);
}
}
&.disabled :deep(.el-upload-dragger) {
cursor: not-allowed;
opacity: 0.6;
&:hover {
border-color: var(--el-border-color);
background-color: transparent;
}
}
.upload-content-inner {
text-align: center;
.upload-icon {
font-size: 48px;
color: var(--el-color-primary);
margin-bottom: 16px;
}
.upload-title {
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.upload-hint {
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
}
.selected-files {
margin-top: 24px;
.files-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h4 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 16px;
}
}
.files-list {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
.file-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: background-color 0.3s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: var(--el-fill-color-light);
}
.file-icon {
margin-right: 12px;
.file-type {
width: 32px;
height: 32px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
&.docx, &.doc {
background-color: #2b579a;
}
&.pptx, &.ppt {
background-color: #d24726;
}
&.xlsx, &.xls {
background-color: #207245;
}
&.pdf {
background-color: #ff0000;
}
}
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-details {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--el-text-color-secondary);
@media (max-width: 480px) {
flex-direction: column;
gap: 2px;
}
}
}
}
}
}
.form-tip {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background-color: var(--el-color-info-light-9);
border-radius: 4px;
font-size: 13px;
color: var(--el-color-info);
line-height: 1.4;
.el-icon {
margin-top: 1px;
flex-shrink: 0;
}
}
.translation-actions {
display: flex;
gap: 12px;
@media (max-width: 480px) {
flex-direction: column;
.el-button {
width: 100%;
}
}
}
.upload-progress {
.overall-progress {
margin-bottom: 24px;
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
color: var(--el-text-color-regular);
}
}
.files-progress {
.file-progress-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
&.success {
.file-status {
color: var(--el-color-success);
}
}
&.error {
.file-status {
color: var(--el-color-danger);
}
}
&.uploading {
.file-status {
color: var(--el-color-primary);
}
}
.file-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.file-icon {
margin-right: 12px;
.file-type {
width: 28px;
height: 28px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: bold;
color: white;
&.docx, &.doc { background-color: #2b579a; }
&.pptx, &.ppt { background-color: #d24726; }
&.xlsx, &.xls { background-color: #207245; }
&.pdf { background-color: #ff0000; }
}
}
.file-details {
flex: 1;
min-width: 0;
.file-name {
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
.el-icon {
font-size: 14px;
}
}
}
}
.file-progress {
width: 120px;
margin: 0 16px;
}
.file-actions {
margin-left: 16px;
}
}
}
.upload-complete-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 24px;
@media (max-width: 480px) {
flex-direction: column;
}
}
}
}
</style>

72
frontend/vite.config.js Normal file
View File

@@ -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
}
})

49
requirements.txt Normal file
View File

@@ -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

42
update_db.py Normal file
View File

@@ -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}")