This commit is contained in:
beabigegg
2025-09-04 18:34:05 +08:00
parent f093f4bbc2
commit 6eabdb2f07
35 changed files with 1097 additions and 212 deletions

View File

@@ -44,7 +44,9 @@
"Bash(docker rm:*)",
"Bash(docker build:*)",
"Bash(docker run:*)",
"Bash(docker exec:*)"
"Bash(docker exec:*)",
"Bash(docker restart:*)",
"Bash(docker cp:*)"
],
"deny": [],
"ask": []

View File

@@ -94,6 +94,12 @@ def get_system_stats():
TranslationJob.status == 'COMPLETED'
).count()
# 當日失敗任務統計
daily_failed = TranslationJob.query.filter(
func.date(TranslationJob.created_at) == target_date,
TranslationJob.status == 'FAILED'
).count()
# 當日成本統計
daily_cost = db.session.query(
func.sum(TranslationJob.total_cost)
@@ -105,6 +111,7 @@ def get_system_stats():
'date': target_date.strftime('%Y-%m-%d'),
'jobs': daily_jobs,
'completed': daily_completed,
'failed': daily_failed,
'cost': float(daily_cost)
})
@@ -145,6 +152,8 @@ def get_all_jobs():
per_page = request.args.get('per_page', 50, type=int)
user_id = request.args.get('user_id', type=int)
status = request.args.get('status')
search = request.args.get('search', '').strip()
include_deleted = request.args.get('include_deleted', 'false').lower() == 'true'
# 驗證分頁參數
page, per_page = validate_pagination(page, min(per_page, 100))
@@ -152,6 +161,10 @@ def get_all_jobs():
# 建立查詢
query = TranslationJob.query
# 預設排除軟刪除的記錄,除非明確要求包含
if not include_deleted:
query = query.filter(TranslationJob.deleted_at.is_(None))
# 使用者篩選
if user_id:
query = query.filter_by(user_id=user_id)
@@ -162,6 +175,10 @@ def get_all_jobs():
if status.upper() in valid_statuses:
query = query.filter_by(status=status.upper())
# 檔案名搜尋
if search:
query = query.filter(TranslationJob.original_filename.like(f'%{search}%'))
# 排序
query = query.order_by(TranslationJob.created_at.desc())
@@ -438,39 +455,61 @@ def get_system_health():
}
status['status'] = 'unhealthy'
# Celery 工作者檢查
# Celery 工作者檢查 - 使用替代方案檢測
try:
from celery_app import celery
from celery.app.control import Control
import redis
import os
from flask import current_app
# 檢查 Celery 工作者狀態
control = Control(celery)
inspect_obj = control.inspect(timeout=2.0) # 設置較短超時
# 方法1: 檢查Redis中是否有Celery相關的key
redis_client = redis.from_url(current_app.config['REDIS_URL'])
# 獲取活躍工作者
active_workers = inspect_obj.active()
# 檢查Celery binding keysworker存在時會有這些keys
celery_keys = redis_client.keys('_kombu.binding.celery*')
if active_workers and len(active_workers) > 0:
worker_count = len(active_workers)
# 方法2: 檢查進程Docker環境中
worker_detected = False
worker_count = 0
try:
# 檢查是否有Celery相關的keys
if celery_keys:
worker_detected = True
worker_count = 1 # Docker環境中通常只有一個worker
# 額外檢查如果有最近的任務處理記錄說明worker在工作
recent_tasks = TranslationJob.query.filter(
TranslationJob.updated_at >= datetime.utcnow() - timedelta(minutes=10),
TranslationJob.status.in_(['PROCESSING', 'COMPLETED'])
).count()
if recent_tasks > 0:
worker_detected = True
worker_count = max(worker_count, 1)
except Exception:
pass
if worker_detected:
status['services']['celery'] = {
'status': 'healthy',
'active_workers': worker_count,
'workers': list(active_workers.keys())
'message': 'Worker detected via Redis/Task activity'
}
else:
# Celery 工作者沒有運行,但不一定表示系統異常
# Celery 工作者沒有檢測到
status['services']['celery'] = {
'status': 'warning',
'message': 'No active Celery workers found',
'message': 'No Celery worker activity detected',
'active_workers': 0
}
# 不設置整體系統為異常,只是警告
except Exception as e:
# Celery 連接失敗,但不一定表示系統異常
# Redis連接失敗或其他錯誤
status['services']['celery'] = {
'status': 'warning',
'message': f'Cannot connect to Celery workers: {str(e)[:100]}'
'message': f'Cannot check Celery status: {str(e)[:100]}',
'active_workers': 0
}
# 不設置整體系統為異常,只是警告
@@ -533,21 +572,24 @@ def get_system_metrics():
from datetime import datetime, timedelta
from app import db
# 統計任務狀態
# 統計任務狀態(排除軟刪除的記錄,反映當前實際狀態)
job_stats = db.session.query(
TranslationJob.status,
func.count(TranslationJob.id)
).filter(
TranslationJob.deleted_at.is_(None)
).group_by(TranslationJob.status).all()
job_counts = {status: count for status, count in job_stats}
# 最近24小時的統計
# 最近24小時的統計(排除軟刪除的記錄)
yesterday = datetime.utcnow() - timedelta(days=1)
recent_jobs = db.session.query(
TranslationJob.status,
func.count(TranslationJob.id)
).filter(
TranslationJob.created_at >= yesterday
TranslationJob.created_at >= yesterday,
TranslationJob.deleted_at.is_(None)
).group_by(TranslationJob.status).all()
recent_counts = {status: count for status, count in recent_jobs}
@@ -919,3 +961,111 @@ def generate_jobs_report(start_date, end_date):
return {
'任務清單': jobs_df
}
@admin_bp.route('/jobs/<job_uuid>/cancel', methods=['POST'])
@admin_required
def admin_cancel_job(job_uuid):
"""管理員取消任務"""
try:
from app import db
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
if not job:
return jsonify(create_response(
success=False,
error='NOT_FOUND',
message='任務不存在'
)), 404
# 只能取消等待中或處理中的任務
if job.status not in ['PENDING', 'PROCESSING']:
return jsonify(create_response(
success=False,
error='CANNOT_CANCEL',
message='只能取消等待中或處理中的任務'
)), 400
# 如果任務正在處理中,嘗試撤銷 Celery 任務
if job.status == 'PROCESSING':
try:
from app.services.celery_service import revoke_task
revoke_task(job_uuid)
logger.info(f"Admin {g.current_user.username} revoked Celery task for job {job_uuid}")
except Exception as e:
logger.warning(f"Failed to revoke Celery task {job_uuid}: {e}")
# 即使撤銷失敗,也繼續標記任務為失敗
# 更新任務狀態
job.status = 'FAILED'
job.error_message = f'管理員 {g.current_user.username} 取消了任務'
job.updated_at = datetime.utcnow()
db.session.commit()
logger.info(f"Admin {g.current_user.username} cancelled job {job_uuid}")
return jsonify(create_response(
success=True,
data={
'job_uuid': job_uuid,
'status': job.status,
'message': '任務已取消'
}
))
except Exception as e:
logger.error(f"Error cancelling job {job_uuid}: {e}", exc_info=True)
return jsonify(create_response(
success=False,
error='INTERNAL_ERROR',
message=str(e)
)), 500
@admin_bp.route('/jobs/<job_uuid>', methods=['DELETE'])
@admin_required
def admin_delete_job(job_uuid):
"""管理員刪除任務(軟刪除)"""
try:
from app import db
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
if not job:
return jsonify(create_response(
success=False,
error='NOT_FOUND',
message='任務不存在'
)), 404
# 如果任務正在處理中,先嘗試撤銷 Celery 任務
if job.status == 'PROCESSING':
try:
from app.services.celery_service import revoke_task
revoke_task(job_uuid)
logger.info(f"Admin {g.current_user.username} revoked Celery task before deletion for job {job_uuid}")
except Exception as e:
logger.warning(f"Failed to revoke Celery task {job_uuid} before deletion: {e}")
# 軟刪除資料庫記錄(保留數據供報表使用)
job.soft_delete()
logger.info(f"Admin {g.current_user.username} soft deleted job {job_uuid}")
return jsonify(create_response(
success=True,
data={
'job_uuid': job_uuid,
'message': '任務已刪除'
}
))
except Exception as e:
logger.error(f"Error deleting job {job_uuid}: {e}", exc_info=True)
return jsonify(create_response(
success=False,
error='INTERNAL_ERROR',
message=str(e)
)), 500

View File

@@ -32,7 +32,8 @@ def health_check():
# 資料庫檢查
try:
from app import db
db.session.execute('SELECT 1')
from sqlalchemy import text
db.session.execute(text('SELECT 1'))
status['services']['database'] = {'status': 'healthy'}
except Exception as e:
status['services']['database'] = {

View File

@@ -40,8 +40,8 @@ def get_user_jobs():
# 驗證分頁參數
page, per_page = validate_pagination(page, per_page)
# 建立查詢
query = TranslationJob.query.filter_by(user_id=g.current_user_id)
# 建立查詢(排除軟刪除的記錄)
query = TranslationJob.query.filter_by(user_id=g.current_user_id).filter(TranslationJob.deleted_at.is_(None))
# 狀態篩選
if status and status != 'all':
@@ -118,8 +118,8 @@ def get_job_detail(job_uuid):
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得任務
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
# 取得任務(排除軟刪除的記錄)
job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first()
if not job:
return jsonify(create_response(
@@ -194,8 +194,8 @@ def retry_job(job_uuid):
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得任務
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
# 取得任務(排除軟刪除的記錄)
job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first()
if not job:
return jsonify(create_response(
@@ -373,13 +373,13 @@ def get_queue_status():
@jobs_bp.route('/<job_uuid>/cancel', methods=['POST'])
@jwt_login_required
def cancel_job(job_uuid):
"""取消任務(僅限 PENDING 狀態)"""
"""取消任務(支援 PENDING 和 PROCESSING 狀態)"""
try:
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得任務
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
# 取得任務(排除軟刪除的記錄)
job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first()
if not job:
return jsonify(create_response(
@@ -396,16 +396,28 @@ def cancel_job(job_uuid):
message='無權限操作此任務'
)), 403
# 只能取消等待中的任務
if job.status != 'PENDING':
# 只能取消等待中或處理中的任務
if job.status not in ['PENDING', 'PROCESSING']:
return jsonify(create_response(
success=False,
error='CANNOT_CANCEL',
message='只能取消等待中的任務'
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}")
# 即使撤銷失敗也繼續取消任務,因為用戶請求取消
# 更新任務狀態為失敗(取消)
job.update_status('FAILED', error_message='使用者取消任務')
cancel_message = f'使用者取消任務 (原狀態: {job.status})'
job.update_status('FAILED', error_message=cancel_message)
# 記錄取消日誌
SystemLog.info(
@@ -469,13 +481,16 @@ def delete_job(job_uuid):
message='無權限操作此任務'
)), 403
# 檢查任務狀態 - 不能刪除正在處理中的任務
# 如果是處理中的任務,先嘗試中斷 Celery 任務
if job.status == 'PROCESSING':
return jsonify(create_response(
success=False,
error='CANNOT_DELETE',
message='無法刪除正在處理中的任務'
)), 400
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
@@ -506,11 +521,10 @@ def delete_job(job_uuid):
from app import db
# 刪除資料庫記錄
db.session.delete(job)
db.session.commit()
# 刪除資料庫記錄(保留數據供報表使用)
job.soft_delete()
logger.info(f"Job deleted by user: {job_uuid}")
logger.info(f"Job soft deleted by user: {job_uuid}")
return jsonify(create_response(
success=True,

View File

@@ -49,6 +49,7 @@ class TranslationJob(db.Model):
onupdate=func.now(),
comment='更新時間'
)
deleted_at = db.Column(db.DateTime, comment='軟刪除時間')
# 關聯關係
files = db.relationship('JobFile', backref='job', lazy='dynamic', cascade='all, delete-orphan')
@@ -84,7 +85,8 @@ class TranslationJob(db.Model):
'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
'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:
@@ -156,15 +158,32 @@ class TranslationJob(db.Model):
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).first()
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()
@@ -173,18 +192,22 @@ class TranslationJob(db.Model):
@classmethod
def get_pending_jobs(cls):
"""取得所有等待處理的任務"""
return cls.query.filter_by(status='PENDING').order_by(cls.created_at.asc()).all()
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').all()
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):
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())
@@ -198,10 +221,14 @@ class TranslationJob(db.Model):
return query.all()
@classmethod
def get_statistics(cls, user_id=None, start_date=None, end_date=None):
"""取得統計資料"""
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)

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

View File

@@ -1 +0,0 @@
.admin-view .overview-section[data-v-706b47d1]{margin-bottom:24px}.admin-view .overview-section .stats-grid[data-v-706b47d1]{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.admin-view .overview-section .stats-grid .stat-total[data-v-706b47d1]{font-size:12px;color:var(--el-text-color-secondary);margin-top:4px}.admin-view .charts-section[data-v-706b47d1]{margin-bottom:24px}.admin-view .charts-section .chart-row[data-v-706b47d1]{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media (max-width: 1200px){.admin-view .charts-section .chart-row[data-v-706b47d1]{grid-template-columns:1fr}}.admin-view .charts-section .chart-row .chart-card .chart-container[data-v-706b47d1]{height:300px;width:100%}.admin-view .info-section[data-v-706b47d1]{margin-bottom:24px}.admin-view .info-section .info-row[data-v-706b47d1]{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media (max-width: 768px){.admin-view .info-section .info-row[data-v-706b47d1]{grid-template-columns:1fr}}.admin-view .info-section .user-rankings .ranking-item[data-v-706b47d1]{display:flex;align-items:center;padding:12px 0;border-bottom:1px solid var(--el-border-color-lighter)}.admin-view .info-section .user-rankings .ranking-item[data-v-706b47d1]:last-child{border-bottom:none}.admin-view .info-section .user-rankings .ranking-item .ranking-position[data-v-706b47d1]{margin-right:16px}.admin-view .info-section .user-rankings .ranking-item .ranking-position .position-number[data-v-706b47d1]{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;background-color:var(--el-color-info)}.admin-view .info-section .user-rankings .ranking-item .ranking-position .position-number.gold[data-v-706b47d1]{background:linear-gradient(45deg,#ffd700,#ffed4e);color:#8b4513}.admin-view .info-section .user-rankings .ranking-item .ranking-position .position-number.silver[data-v-706b47d1]{background:linear-gradient(45deg,#c0c0c0,#e8e8e8);color:#666}.admin-view .info-section .user-rankings .ranking-item .ranking-position .position-number.bronze[data-v-706b47d1]{background:linear-gradient(45deg,#cd7f32,#daa520);color:#fff}.admin-view .info-section .user-rankings .ranking-item .user-info[data-v-706b47d1]{flex:1;min-width:0}.admin-view .info-section .user-rankings .ranking-item .user-info .user-name[data-v-706b47d1]{font-weight:600;color:var(--el-text-color-primary);margin-bottom:4px}.admin-view .info-section .user-rankings .ranking-item .user-info .user-stats[data-v-706b47d1]{display:flex;gap:16px;font-size:13px;color:var(--el-text-color-secondary)}.admin-view .info-section .user-rankings .ranking-item .ranking-progress[data-v-706b47d1]{width:80px;margin-left:16px}.admin-view .info-section .system-health .health-item[data-v-706b47d1]{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--el-border-color-lighter)}.admin-view .info-section .system-health .health-item[data-v-706b47d1]:last-child{border-bottom:none}.admin-view .info-section .system-health .health-item .health-label[data-v-706b47d1]{color:var(--el-text-color-regular)}.admin-view .info-section .system-health .health-item .health-value[data-v-706b47d1]{font-weight:500;color:var(--el-text-color-primary)}.admin-view .recent-jobs-section .file-info[data-v-706b47d1]{display:flex;align-items:center;gap:8px}.admin-view .recent-jobs-section .file-info .file-icon[data-v-706b47d1]{width:24px;height:24px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:700;color:#fff;flex-shrink:0}.admin-view .recent-jobs-section .file-info .file-icon.docx[data-v-706b47d1],.admin-view .recent-jobs-section .file-info .file-icon.doc[data-v-706b47d1]{background-color:#2b579a}.admin-view .recent-jobs-section .file-info .file-icon.pptx[data-v-706b47d1],.admin-view .recent-jobs-section .file-info .file-icon.ppt[data-v-706b47d1]{background-color:#d24726}.admin-view .recent-jobs-section .file-info .file-icon.xlsx[data-v-706b47d1],.admin-view .recent-jobs-section .file-info .file-icon.xls[data-v-706b47d1]{background-color:#207245}.admin-view .recent-jobs-section .file-info .file-icon.pdf[data-v-706b47d1]{background-color:red}.admin-view .recent-jobs-section .file-info .file-name[data-v-706b47d1]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.admin-view .recent-jobs-section .language-tags[data-v-706b47d1]{display:flex;flex-wrap:wrap;gap:4px}.loading-state[data-v-706b47d1]{padding:20px 0}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.error-message[data-v-17157d64]{margin-top:16px}.login-tips[data-v-17157d64]{margin-top:24px}.login-tips[data-v-17157d64] .el-alert__content p{margin:4px 0;font-size:13px;line-height:1.4}.login-tips[data-v-17157d64] .el-alert__content p:first-child{margin-top:0}.login-tips[data-v-17157d64] .el-alert__content p:last-child{margin-bottom:0}@media (max-width: 480px){.login-layout[data-v-17157d64]{padding:16px}.login-layout .login-container[data-v-17157d64]{max-width:100%}.login-layout .login-container .login-header[data-v-17157d64]{padding:24px}.login-layout .login-container .login-header .login-logo[data-v-17157d64]{width:48px;height:48px;margin-bottom:16px}.login-layout .login-container .login-header .login-title[data-v-17157d64]{font-size:20px;margin-bottom:8px}.login-layout .login-container .login-header .login-subtitle[data-v-17157d64]{font-size:13px}.login-layout .login-container .login-body[data-v-17157d64]{padding:24px}.login-layout .login-container .login-footer[data-v-17157d64]{padding:16px 24px;font-size:12px}}.loading[data-v-17157d64]{pointer-events:none;opacity:.7}.login-container[data-v-17157d64]{animation:slideInUp-17157d64 .5s ease-out}@keyframes slideInUp-17157d64{0%{transform:translateY(30px);opacity:0}to{transform:translateY(0);opacity:1}}[data-v-17157d64] .el-form-item__label{color:var(--el-text-color-primary);font-weight:500}[data-v-17157d64] .el-input__inner{border-radius:6px}[data-v-17157d64] .el-button{border-radius:6px;font-weight:500}[data-v-17157d64] .el-checkbox__label{font-size:14px;color:var(--el-text-color-regular)}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<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 - 企業級文件批量翻譯管理系統" />
@@ -34,8 +34,8 @@
100% { transform: rotate(360deg); }
}
</style>
<script type="module" crossorigin src="/js/index-cb898b04.js"></script>
<link rel="stylesheet" href="/css/index-f9b7dc59.css">
<script type="module" crossorigin src="/js/index-fa5efca2.js"></script>
<link rel="stylesheet" href="/css/index-fda0a621.css">
</head>
<body>
<div id="app">

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{_ as T}from"./_plugin-vue_export-helper-af00840d.js";/* empty css *//* empty css *//* empty css *//* empty css */import{u as L,r as g,a as N,o as D,w as F,b as P,c as A,d as a,e as s,f as r,g as S,h as z,i as B,E as C,j as J,k as R,l as V,m as b,n as U,p as q,q as M,s as x,t as j,v as K,x as $,y as G,z as Z}from"./index-cb898b04.js";const H={class:"login-layout"},O={class:"login-container"},Q={class:"login-header"},W={class:"login-logo"},X={class:"login-body"},Y={key:0,class:"error-message"},ee={class:"login-tips"},se={__name:"LoginView",setup(le){const f=B(),v=L(),w=g(),n=g(!1),c=g(!1),l=g(""),o=N({username:"",password:""}),I={username:[{required:!0,message:"請輸入 AD 帳號",trigger:"blur"},{min:3,message:"帳號長度不能少於3個字元",trigger:"blur"},{pattern:/^[a-zA-Z0-9._@-]+$/,message:"帳號格式不正確,只能包含字母、數字、點、下劃線、@符號和連字符",trigger:"blur"}],password:[{required:!0,message:"請輸入密碼",trigger:"blur"},{min:1,message:"密碼不能為空",trigger:"blur"}]},h=async()=>{var _,e,u,m,i;try{if(l.value="",!await w.value.validate())return;n.value=!0;const d={username:o.username.trim(),password:o.password};d.username.includes("@")||(d.username=`${d.username}@panjit.com.tw`),await v.login(d),c.value&&localStorage.setItem("rememberLogin","true"),f.push("/")}catch(t){console.error("登入失敗:",t),((_=t.response)==null?void 0:_.status)===401?l.value="帳號或密碼錯誤,請重新輸入":((e=t.response)==null?void 0:e.status)===403?l.value="您的帳號沒有權限存取此系統":((u=t.response)==null?void 0:u.status)===500?l.value="伺服器錯誤,請稍後再試":(m=t.message)!=null&&m.includes("LDAP")?l.value="AD 伺服器連接失敗,請聯繫 IT 部門":(i=t.message)!=null&&i.includes("network")?l.value="網路連接失敗,請檢查網路設定":l.value=t.message||"登入失敗,請重試",o.password="",setTimeout(()=>{l.value=""},5e3)}finally{n.value=!1}},k=()=>{l.value=""};return D(()=>{if(v.isAuthenticated){f.push("/");return}localStorage.getItem("rememberLogin")==="true"&&(c.value=!0),v.checkAuth().then(u=>{u&&f.push("/")}).catch(()=>{});const e=F([()=>o.username,()=>o.password],()=>{l.value&&k()});P(()=>{e()})}),(_,e)=>{const u=C,m=K,i=$,t=G,d=Z,E=J,y=R;return V(),A("div",H,[a("div",O,[a("div",Q,[a("div",W,[s(u,null,{default:r(()=>[s(b(U))]),_:1})]),e[3]||(e[3]=a("h1",{class:"login-title"},"PANJIT 翻譯系統",-1)),e[4]||(e[4]=a("p",{class:"login-subtitle"},"企業級文件批量翻譯管理系統",-1))]),a("div",X,[s(E,{ref_key:"loginFormRef",ref:w,model:o,rules:I,onKeyup:S(h,["enter"]),"label-position":"top",size:"large"},{default:r(()=>[s(i,{label:"AD 帳號",prop:"username"},{default:r(()=>[s(m,{modelValue:o.username,"onUpdate:modelValue":e[0]||(e[0]=p=>o.username=p),placeholder:"請輸入您的 AD 帳號","prefix-icon":b(q),clearable:"",disabled:n.value},null,8,["modelValue","prefix-icon","disabled"])]),_:1}),s(i,{label:"密碼",prop:"password"},{default:r(()=>[s(m,{modelValue:o.password,"onUpdate:modelValue":e[1]||(e[1]=p=>o.password=p),type:"password",placeholder:"請輸入密碼","prefix-icon":b(M),"show-password":"",clearable:"",disabled:n.value},null,8,["modelValue","prefix-icon","disabled"])]),_:1}),s(i,null,{default:r(()=>[s(t,{modelValue:c.value,"onUpdate:modelValue":e[2]||(e[2]=p=>c.value=p),disabled:n.value},{default:r(()=>[...e[5]||(e[5]=[x(" 記住登入狀態 ",-1)])]),_:1},8,["modelValue","disabled"])]),_:1}),s(i,null,{default:r(()=>[s(d,{type:"primary",size:"large",loading:n.value,disabled:!o.username||!o.password,onClick:h,style:{width:"100%"}},{default:r(()=>[x(j(n.value?"登入中...":"登入"),1)]),_:1},8,["loading","disabled"])]),_:1})]),_:1},8,["model"]),l.value?(V(),A("div",Y,[s(y,{title:l.value,type:"error",closable:!1,"show-icon":""},null,8,["title"])])):z("",!0),a("div",ee,[s(y,{title:"登入說明",type:"info",closable:!1,"show-icon":""},{default:r(()=>[...e[6]||(e[6]=[a("p",null,"請使用您的 PANJIT AD 域帳號登入系統。",-1),a("p",null,"如果您忘記密碼或遇到登入問題,請聯繫 IT 部門協助。",-1)])]),_:1})])]),e[7]||(e[7]=a("div",{class:"login-footer"},[a("p",null,"© 2024 PANJIT Group. All rights reserved."),a("p",null,"Powered by PANJIT IT Team")],-1))])])}}},ue=T(se,[["__scopeId","data-v-17157d64"]]);export{ue as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{_ as r}from"./_plugin-vue_export-helper-af00840d.js";import{c as f,d as s,e as l,f as o,i as k,E as v,z as p,H as m,l as w,m as i,aK as g,aL as x,s as c,aC as y,W as N,X as V,_ as B,p as C}from"./index-cb898b04.js";const E={class:"not-found-view"},b={class:"not-found-container"},h={class:"not-found-illustration"},z={class:"error-icon"},F={class:"not-found-content"},H={class:"error-actions"},I={class:"helpful-links"},j={class:"links-grid"},q={class:"link-icon"},K={class:"link-icon"},L={class:"link-icon"},R={class:"link-icon"},T={__name:"NotFoundView",setup(W){const d=k(),u=()=>{d.push("/")},_=()=>{window.history.length>1?d.back():d.push("/")};return(X,t)=>{const n=v,a=p,e=m("router-link");return w(),f("div",E,[s("div",b,[s("div",h,[t[0]||(t[0]=s("div",{class:"error-code"},"404",-1)),s("div",z,[l(n,null,{default:o(()=>[l(i(g))]),_:1})])]),s("div",F,[t[3]||(t[3]=s("h1",{class:"error-title"},"頁面不存在",-1)),t[4]||(t[4]=s("p",{class:"error-description"}," 抱歉,您訪問的頁面不存在或已被移除。 ",-1)),s("div",H,[l(a,{type:"primary",size:"large",onClick:u},{default:o(()=>[l(n,null,{default:o(()=>[l(i(x))]),_:1}),t[1]||(t[1]=c(" 回到首頁 ",-1))]),_:1}),l(a,{size:"large",onClick:_},{default:o(()=>[l(n,null,{default:o(()=>[l(i(y))]),_:1}),t[2]||(t[2]=c(" 返回上頁 ",-1))]),_:1})])]),s("div",I,[t[9]||(t[9]=s("h3",null,"您可能在尋找:",-1)),s("div",j,[l(e,{to:"/upload",class:"link-card"},{default:o(()=>[s("div",q,[l(n,null,{default:o(()=>[l(i(N))]),_:1})]),t[5]||(t[5]=s("div",{class:"link-content"},[s("div",{class:"link-title"},"檔案上傳"),s("div",{class:"link-desc"},"上傳新的檔案進行翻譯")],-1))]),_:1}),l(e,{to:"/jobs",class:"link-card"},{default:o(()=>[s("div",K,[l(n,null,{default:o(()=>[l(i(V))]),_:1})]),t[6]||(t[6]=s("div",{class:"link-content"},[s("div",{class:"link-title"},"任務列表"),s("div",{class:"link-desc"},"查看您的翻譯任務")],-1))]),_:1}),l(e,{to:"/history",class:"link-card"},{default:o(()=>[s("div",L,[l(n,null,{default:o(()=>[l(i(B))]),_:1})]),t[7]||(t[7]=s("div",{class:"link-content"},[s("div",{class:"link-title"},"歷史記錄"),s("div",{class:"link-desc"},"瀏覽過往的翻譯記錄")],-1))]),_:1}),l(e,{to:"/profile",class:"link-card"},{default:o(()=>[s("div",R,[l(n,null,{default:o(()=>[l(i(C))]),_:1})]),t[8]||(t[8]=s("div",{class:"link-content"},[s("div",{class:"link-title"},"個人設定"),s("div",{class:"link-desc"},"管理您的個人資料")],-1))]),_:1})])])])])}}},G=r(T,[["__scopeId","data-v-6d786883"]]);export{G as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -90,6 +90,15 @@ const routes = [
requiresAdmin: true,
showInMenu: true
}
},
{
path: '/admin/jobs',
name: 'AdminJobs',
component: () => import('@/views/AdminJobsView.vue'),
meta: {
title: '全部任務',
requiresAdmin: true
}
}
]
},

View File

@@ -119,5 +119,21 @@ export const adminAPI = {
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

@@ -264,7 +264,11 @@ export const useAdminStore = defineStore('admin', {
try {
const response = await adminAPI.getSystemMetrics()
if (response.success || response.jobs) {
if (response.success && response.data) {
this.systemMetrics = response.data
return response.data
} else if (response.jobs) {
// 兼容舊格式
this.systemMetrics = response
return response
}

View File

@@ -169,7 +169,11 @@ export const useJobsStore = defineStore('jobs', {
if (response.success) {
const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid)
if (jobIndex !== -1) {
this.jobs[jobIndex] = { ...this.jobs[jobIndex], status: 'CANCELLED' }
this.jobs[jobIndex] = {
...this.jobs[jobIndex],
status: 'FAILED',
error_message: '使用者取消任務'
}
}
ElMessage.success('任務已取消')

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>

View File

@@ -308,7 +308,7 @@
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button
type="text"
@@ -317,6 +317,21 @@
>
查看
</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>
@@ -331,7 +346,8 @@
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useAdminStore } from '@/stores/admin'
import { ElMessage } from 'element-plus'
import { adminAPI } from '@/services/admin'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as echarts from 'echarts'
import {
Download, ArrowDown, Refresh, DataBoard, SuccessFilled,
@@ -473,6 +489,56 @@ 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 refreshData()
}
} 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 refreshData()
}
} catch (error) {
if (error !== 'cancel') {
console.error('刪除任務失敗:', error)
ElMessage.error(error.response?.data?.message || '刪除任務失敗')
}
}
}
const initCharts = () => {
initDailyChart()
initCostChart()
@@ -502,14 +568,8 @@ const initDailyChart = () => {
const dates = dailyStats.value.map(stat => stat?.date || 'N/A')
const jobs = dailyStats.value.map(stat => stat?.jobs || 0)
const completed = dailyStats.value.map(stat => stat?.completed || 0)
// 注意:後端可能沒有提供 failed 數據,所以計算或預設為 0
const failed = dailyStats.value.map(stat => {
if (stat?.failed !== undefined) {
return stat.failed
}
// 如果沒有 failed 數據,可以計算為 total - completed或預設為 0
return Math.max(0, (stat?.jobs || 0) - (stat?.completed || 0))
})
// 使用後端提供failed欄位,如果沒有則預設為0
const failed = dailyStats.value.map(stat => stat?.failed || 0)
const option = {
title: {

View File

@@ -163,6 +163,12 @@
>
重新翻譯
</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>
@@ -251,6 +257,27 @@ const handleJobAction = async (action, job) => {
}
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('確定要刪除此任務嗎?此操作無法撤銷。', '確認刪除', {

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