16th_fix
This commit is contained in:
@@ -44,7 +44,9 @@
|
|||||||
"Bash(docker rm:*)",
|
"Bash(docker rm:*)",
|
||||||
"Bash(docker build:*)",
|
"Bash(docker build:*)",
|
||||||
"Bash(docker run:*)",
|
"Bash(docker run:*)",
|
||||||
"Bash(docker exec:*)"
|
"Bash(docker exec:*)",
|
||||||
|
"Bash(docker restart:*)",
|
||||||
|
"Bash(docker cp:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
188
app/api/admin.py
188
app/api/admin.py
@@ -94,6 +94,12 @@ def get_system_stats():
|
|||||||
TranslationJob.status == 'COMPLETED'
|
TranslationJob.status == 'COMPLETED'
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
|
# 當日失敗任務統計
|
||||||
|
daily_failed = TranslationJob.query.filter(
|
||||||
|
func.date(TranslationJob.created_at) == target_date,
|
||||||
|
TranslationJob.status == 'FAILED'
|
||||||
|
).count()
|
||||||
|
|
||||||
# 當日成本統計
|
# 當日成本統計
|
||||||
daily_cost = db.session.query(
|
daily_cost = db.session.query(
|
||||||
func.sum(TranslationJob.total_cost)
|
func.sum(TranslationJob.total_cost)
|
||||||
@@ -105,6 +111,7 @@ def get_system_stats():
|
|||||||
'date': target_date.strftime('%Y-%m-%d'),
|
'date': target_date.strftime('%Y-%m-%d'),
|
||||||
'jobs': daily_jobs,
|
'jobs': daily_jobs,
|
||||||
'completed': daily_completed,
|
'completed': daily_completed,
|
||||||
|
'failed': daily_failed,
|
||||||
'cost': float(daily_cost)
|
'cost': float(daily_cost)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -145,6 +152,8 @@ def get_all_jobs():
|
|||||||
per_page = request.args.get('per_page', 50, type=int)
|
per_page = request.args.get('per_page', 50, type=int)
|
||||||
user_id = request.args.get('user_id', type=int)
|
user_id = request.args.get('user_id', type=int)
|
||||||
status = request.args.get('status')
|
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))
|
page, per_page = validate_pagination(page, min(per_page, 100))
|
||||||
@@ -152,6 +161,10 @@ def get_all_jobs():
|
|||||||
# 建立查詢
|
# 建立查詢
|
||||||
query = TranslationJob.query
|
query = TranslationJob.query
|
||||||
|
|
||||||
|
# 預設排除軟刪除的記錄,除非明確要求包含
|
||||||
|
if not include_deleted:
|
||||||
|
query = query.filter(TranslationJob.deleted_at.is_(None))
|
||||||
|
|
||||||
# 使用者篩選
|
# 使用者篩選
|
||||||
if user_id:
|
if user_id:
|
||||||
query = query.filter_by(user_id=user_id)
|
query = query.filter_by(user_id=user_id)
|
||||||
@@ -161,6 +174,10 @@ def get_all_jobs():
|
|||||||
valid_statuses = ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRY']
|
valid_statuses = ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRY']
|
||||||
if status.upper() in valid_statuses:
|
if status.upper() in valid_statuses:
|
||||||
query = query.filter_by(status=status.upper())
|
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())
|
query = query.order_by(TranslationJob.created_at.desc())
|
||||||
@@ -438,39 +455,61 @@ def get_system_health():
|
|||||||
}
|
}
|
||||||
status['status'] = 'unhealthy'
|
status['status'] = 'unhealthy'
|
||||||
|
|
||||||
# Celery 工作者檢查
|
# Celery 工作者檢查 - 使用替代方案檢測
|
||||||
try:
|
try:
|
||||||
from celery_app import celery
|
import redis
|
||||||
from celery.app.control import Control
|
import os
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
# 檢查 Celery 工作者狀態
|
# 方法1: 檢查Redis中是否有Celery相關的key
|
||||||
control = Control(celery)
|
redis_client = redis.from_url(current_app.config['REDIS_URL'])
|
||||||
inspect_obj = control.inspect(timeout=2.0) # 設置較短超時
|
|
||||||
|
|
||||||
# 獲取活躍工作者
|
# 檢查Celery binding keys(worker存在時會有這些keys)
|
||||||
active_workers = inspect_obj.active()
|
celery_keys = redis_client.keys('_kombu.binding.celery*')
|
||||||
|
|
||||||
if active_workers and len(active_workers) > 0:
|
# 方法2: 檢查進程(Docker環境中)
|
||||||
worker_count = len(active_workers)
|
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['services']['celery'] = {
|
||||||
'status': 'healthy',
|
'status': 'healthy',
|
||||||
'active_workers': worker_count,
|
'active_workers': worker_count,
|
||||||
'workers': list(active_workers.keys())
|
'message': 'Worker detected via Redis/Task activity'
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Celery 工作者沒有運行,但不一定表示系統異常
|
# Celery 工作者沒有檢測到
|
||||||
status['services']['celery'] = {
|
status['services']['celery'] = {
|
||||||
'status': 'warning',
|
'status': 'warning',
|
||||||
'message': 'No active Celery workers found',
|
'message': 'No Celery worker activity detected',
|
||||||
'active_workers': 0
|
'active_workers': 0
|
||||||
}
|
}
|
||||||
# 不設置整體系統為異常,只是警告
|
# 不設置整體系統為異常,只是警告
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Celery 連接失敗,但不一定表示系統異常
|
# Redis連接失敗或其他錯誤
|
||||||
status['services']['celery'] = {
|
status['services']['celery'] = {
|
||||||
'status': 'warning',
|
'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 datetime import datetime, timedelta
|
||||||
from app import db
|
from app import db
|
||||||
|
|
||||||
# 統計任務狀態
|
# 統計任務狀態(排除軟刪除的記錄,反映當前實際狀態)
|
||||||
job_stats = db.session.query(
|
job_stats = db.session.query(
|
||||||
TranslationJob.status,
|
TranslationJob.status,
|
||||||
func.count(TranslationJob.id)
|
func.count(TranslationJob.id)
|
||||||
|
).filter(
|
||||||
|
TranslationJob.deleted_at.is_(None)
|
||||||
).group_by(TranslationJob.status).all()
|
).group_by(TranslationJob.status).all()
|
||||||
|
|
||||||
job_counts = {status: count for status, count in job_stats}
|
job_counts = {status: count for status, count in job_stats}
|
||||||
|
|
||||||
# 最近24小時的統計
|
# 最近24小時的統計(排除軟刪除的記錄)
|
||||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||||
recent_jobs = db.session.query(
|
recent_jobs = db.session.query(
|
||||||
TranslationJob.status,
|
TranslationJob.status,
|
||||||
func.count(TranslationJob.id)
|
func.count(TranslationJob.id)
|
||||||
).filter(
|
).filter(
|
||||||
TranslationJob.created_at >= yesterday
|
TranslationJob.created_at >= yesterday,
|
||||||
|
TranslationJob.deleted_at.is_(None)
|
||||||
).group_by(TranslationJob.status).all()
|
).group_by(TranslationJob.status).all()
|
||||||
|
|
||||||
recent_counts = {status: count for status, count in recent_jobs}
|
recent_counts = {status: count for status, count in recent_jobs}
|
||||||
@@ -918,4 +960,112 @@ def generate_jobs_report(start_date, end_date):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'任務清單': jobs_df
|
'任務清單': 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
|
@@ -32,7 +32,8 @@ def health_check():
|
|||||||
# 資料庫檢查
|
# 資料庫檢查
|
||||||
try:
|
try:
|
||||||
from app import db
|
from app import db
|
||||||
db.session.execute('SELECT 1')
|
from sqlalchemy import text
|
||||||
|
db.session.execute(text('SELECT 1'))
|
||||||
status['services']['database'] = {'status': 'healthy'}
|
status['services']['database'] = {'status': 'healthy'}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status['services']['database'] = {
|
status['services']['database'] = {
|
||||||
|
@@ -40,8 +40,8 @@ def get_user_jobs():
|
|||||||
# 驗證分頁參數
|
# 驗證分頁參數
|
||||||
page, per_page = validate_pagination(page, per_page)
|
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':
|
if status and status != 'all':
|
||||||
@@ -118,8 +118,8 @@ def get_job_detail(job_uuid):
|
|||||||
# 驗證 UUID 格式
|
# 驗證 UUID 格式
|
||||||
validate_job_uuid(job_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:
|
if not job:
|
||||||
return jsonify(create_response(
|
return jsonify(create_response(
|
||||||
@@ -194,8 +194,8 @@ def retry_job(job_uuid):
|
|||||||
# 驗證 UUID 格式
|
# 驗證 UUID 格式
|
||||||
validate_job_uuid(job_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:
|
if not job:
|
||||||
return jsonify(create_response(
|
return jsonify(create_response(
|
||||||
@@ -373,13 +373,13 @@ def get_queue_status():
|
|||||||
@jobs_bp.route('/<job_uuid>/cancel', methods=['POST'])
|
@jobs_bp.route('/<job_uuid>/cancel', methods=['POST'])
|
||||||
@jwt_login_required
|
@jwt_login_required
|
||||||
def cancel_job(job_uuid):
|
def cancel_job(job_uuid):
|
||||||
"""取消任務(僅限 PENDING 狀態)"""
|
"""取消任務(支援 PENDING 和 PROCESSING 狀態)"""
|
||||||
try:
|
try:
|
||||||
# 驗證 UUID 格式
|
# 驗證 UUID 格式
|
||||||
validate_job_uuid(job_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:
|
if not job:
|
||||||
return jsonify(create_response(
|
return jsonify(create_response(
|
||||||
@@ -396,16 +396,28 @@ def cancel_job(job_uuid):
|
|||||||
message='無權限操作此任務'
|
message='無權限操作此任務'
|
||||||
)), 403
|
)), 403
|
||||||
|
|
||||||
# 只能取消等待中的任務
|
# 只能取消等待中或處理中的任務
|
||||||
if job.status != 'PENDING':
|
if job.status not in ['PENDING', 'PROCESSING']:
|
||||||
return jsonify(create_response(
|
return jsonify(create_response(
|
||||||
success=False,
|
success=False,
|
||||||
error='CANNOT_CANCEL',
|
error='CANNOT_CANCEL',
|
||||||
message='只能取消等待中的任務'
|
message='只能取消等待中或處理中的任務'
|
||||||
)), 400
|
)), 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(
|
SystemLog.info(
|
||||||
@@ -469,13 +481,16 @@ def delete_job(job_uuid):
|
|||||||
message='無權限操作此任務'
|
message='無權限操作此任務'
|
||||||
)), 403
|
)), 403
|
||||||
|
|
||||||
# 檢查任務狀態 - 不能刪除正在處理中的任務
|
# 如果是處理中的任務,先嘗試中斷 Celery 任務
|
||||||
if job.status == 'PROCESSING':
|
if job.status == 'PROCESSING':
|
||||||
return jsonify(create_response(
|
try:
|
||||||
success=False,
|
from app.services.celery_service import revoke_task
|
||||||
error='CANNOT_DELETE',
|
# 嘗試撤銷 Celery 任務
|
||||||
message='無法刪除正在處理中的任務'
|
revoke_task(job.job_uuid)
|
||||||
)), 400
|
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 os
|
||||||
@@ -506,11 +521,10 @@ def delete_job(job_uuid):
|
|||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
|
||||||
# 刪除資料庫記錄
|
# 軟刪除資料庫記錄(保留數據供報表使用)
|
||||||
db.session.delete(job)
|
job.soft_delete()
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
logger.info(f"Job deleted by user: {job_uuid}")
|
logger.info(f"Job soft deleted by user: {job_uuid}")
|
||||||
|
|
||||||
return jsonify(create_response(
|
return jsonify(create_response(
|
||||||
success=True,
|
success=True,
|
||||||
|
@@ -49,6 +49,7 @@ class TranslationJob(db.Model):
|
|||||||
onupdate=func.now(),
|
onupdate=func.now(),
|
||||||
comment='更新時間'
|
comment='更新時間'
|
||||||
)
|
)
|
||||||
|
deleted_at = db.Column(db.DateTime, comment='軟刪除時間')
|
||||||
|
|
||||||
# 關聯關係
|
# 關聯關係
|
||||||
files = db.relationship('JobFile', backref='job', lazy='dynamic', cascade='all, delete-orphan')
|
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,
|
'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,
|
'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,
|
'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:
|
if include_files:
|
||||||
@@ -156,15 +158,32 @@ class TranslationJob(db.Model):
|
|||||||
self.updated_at = datetime.utcnow()
|
self.updated_at = datetime.utcnow()
|
||||||
db.session.commit()
|
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
|
@classmethod
|
||||||
def get_queue_position(cls, job_uuid):
|
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:
|
if not job:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
position = cls.query.filter(
|
position = cls.query.filter(
|
||||||
cls.status == 'PENDING',
|
cls.status == 'PENDING',
|
||||||
|
cls.deleted_at.is_(None),
|
||||||
cls.created_at < job.created_at
|
cls.created_at < job.created_at
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
@@ -173,18 +192,22 @@ class TranslationJob(db.Model):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_pending_jobs(cls):
|
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
|
@classmethod
|
||||||
def get_processing_jobs(cls):
|
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
|
@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)
|
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':
|
if status and status != 'all':
|
||||||
query = query.filter_by(status=status.upper())
|
query = query.filter_by(status=status.upper())
|
||||||
|
|
||||||
@@ -198,10 +221,14 @@ class TranslationJob(db.Model):
|
|||||||
return query.all()
|
return query.all()
|
||||||
|
|
||||||
@classmethod
|
@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
|
query = cls.query
|
||||||
|
|
||||||
|
# 報表統計預設包含已刪除記錄以確保數據完整性
|
||||||
|
if not include_deleted:
|
||||||
|
query = query.filter(cls.deleted_at.is_(None))
|
||||||
|
|
||||||
if user_id:
|
if user_id:
|
||||||
query = query.filter_by(user_id=user_id)
|
query = query.filter_by(user_id=user_id)
|
||||||
|
|
||||||
|
137
app/services/celery_service.py
Normal file
137
app/services/celery_service.py
Normal 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
|
1
frontend/dist/css/AdminView-49370c9f.css
vendored
1
frontend/dist/css/AdminView-49370c9f.css
vendored
@@ -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}
|
|
1
frontend/dist/css/HomeView-6bf50db9.css
vendored
1
frontend/dist/css/HomeView-6bf50db9.css
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/css/JobDetailView-9de1c91d.css
vendored
1
frontend/dist/css/JobDetailView-9de1c91d.css
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/css/JobListView-758af797.css
vendored
1
frontend/dist/css/JobListView-758af797.css
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/css/LoginView-d222ef5b.css
vendored
1
frontend/dist/css/LoginView-d222ef5b.css
vendored
@@ -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)}
|
|
1
frontend/dist/css/MainLayout-70ec3510.css
vendored
1
frontend/dist/css/MainLayout-70ec3510.css
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/css/index-f9b7dc59.css
vendored
1
frontend/dist/css/index-f9b7dc59.css
vendored
File diff suppressed because one or more lines are too long
6
frontend/dist/index.html
vendored
6
frontend/dist/index.html
vendored
@@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-TW">
|
<html lang="zh-TW">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PANJIT Document Translator</title>
|
<title>PANJIT Document Translator</title>
|
||||||
<meta name="description" content="PANJIT Document Translator Web System - 企業級文件批量翻譯管理系統" />
|
<meta name="description" content="PANJIT Document Translator Web System - 企業級文件批量翻譯管理系統" />
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/js/index-cb898b04.js"></script>
|
<script type="module" crossorigin src="/js/index-fa5efca2.js"></script>
|
||||||
<link rel="stylesheet" href="/css/index-f9b7dc59.css">
|
<link rel="stylesheet" href="/css/index-fda0a621.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
60
frontend/dist/js/AdminView-82426d02.js
vendored
60
frontend/dist/js/AdminView-82426d02.js
vendored
File diff suppressed because one or more lines are too long
2
frontend/dist/js/HistoryView-5a55cb78.js
vendored
2
frontend/dist/js/HistoryView-5a55cb78.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/js/HomeView-2c473b97.js
vendored
1
frontend/dist/js/HomeView-2c473b97.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/js/JobDetailView-fcd3745d.js
vendored
1
frontend/dist/js/JobDetailView-fcd3745d.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/js/JobListView-706a3b08.js
vendored
1
frontend/dist/js/JobListView-706a3b08.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/js/LoginView-d41adadd.js
vendored
1
frontend/dist/js/LoginView-d41adadd.js
vendored
@@ -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};
|
|
1
frontend/dist/js/MainLayout-1cd17884.js
vendored
1
frontend/dist/js/MainLayout-1cd17884.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/js/NotFoundView-642c0b17.js
vendored
1
frontend/dist/js/NotFoundView-642c0b17.js
vendored
@@ -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};
|
|
1
frontend/dist/js/ProfileView-edc0fc70.js
vendored
1
frontend/dist/js/ProfileView-edc0fc70.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/js/UploadView-f997be6d.js
vendored
1
frontend/dist/js/UploadView-f997be6d.js
vendored
File diff suppressed because one or more lines are too long
67
frontend/dist/js/index-cb898b04.js
vendored
67
frontend/dist/js/index-cb898b04.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/js/jobs-6be64b92.js
vendored
1
frontend/dist/js/jobs-6be64b92.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/js/websocket-9a0b24f7.js
vendored
1
frontend/dist/js/websocket-9a0b24f7.js
vendored
File diff suppressed because one or more lines are too long
@@ -90,6 +90,15 @@ const routes = [
|
|||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
showInMenu: true
|
showInMenu: true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/jobs',
|
||||||
|
name: 'AdminJobs',
|
||||||
|
component: () => import('@/views/AdminJobsView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '全部任務',
|
||||||
|
requiresAdmin: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@@ -119,5 +119,21 @@ export const adminAPI = {
|
|||||||
cache_days: 90
|
cache_days: 90
|
||||||
}
|
}
|
||||||
return request.post('/admin/maintenance/cleanup', { ...defaultOptions, ...options })
|
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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -264,7 +264,11 @@ export const useAdminStore = defineStore('admin', {
|
|||||||
try {
|
try {
|
||||||
const response = await adminAPI.getSystemMetrics()
|
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
|
this.systemMetrics = response
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
@@ -169,7 +169,11 @@ export const useJobsStore = defineStore('jobs', {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid)
|
const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid)
|
||||||
if (jobIndex !== -1) {
|
if (jobIndex !== -1) {
|
||||||
this.jobs[jobIndex] = { ...this.jobs[jobIndex], status: 'CANCELLED' }
|
this.jobs[jobIndex] = {
|
||||||
|
...this.jobs[jobIndex],
|
||||||
|
status: 'FAILED',
|
||||||
|
error_message: '使用者取消任務'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ElMessage.success('任務已取消')
|
ElMessage.success('任務已取消')
|
||||||
|
538
frontend/src/views/AdminJobsView.vue
Normal file
538
frontend/src/views/AdminJobsView.vue
Normal 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>
|
@@ -308,7 +308,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="操作" width="80">
|
<el-table-column label="操作" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -317,6 +317,21 @@
|
|||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
</el-button>
|
</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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -331,7 +346,8 @@
|
|||||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAdminStore } from '@/stores/admin'
|
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 * as echarts from 'echarts'
|
||||||
import {
|
import {
|
||||||
Download, ArrowDown, Refresh, DataBoard, SuccessFilled,
|
Download, ArrowDown, Refresh, DataBoard, SuccessFilled,
|
||||||
@@ -473,6 +489,56 @@ const viewJobDetail = (jobUuid) => {
|
|||||||
router.push(`/job/${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 = () => {
|
const initCharts = () => {
|
||||||
initDailyChart()
|
initDailyChart()
|
||||||
initCostChart()
|
initCostChart()
|
||||||
@@ -502,14 +568,8 @@ const initDailyChart = () => {
|
|||||||
const dates = dailyStats.value.map(stat => stat?.date || 'N/A')
|
const dates = dailyStats.value.map(stat => stat?.date || 'N/A')
|
||||||
const jobs = dailyStats.value.map(stat => stat?.jobs || 0)
|
const jobs = dailyStats.value.map(stat => stat?.jobs || 0)
|
||||||
const completed = dailyStats.value.map(stat => stat?.completed || 0)
|
const completed = dailyStats.value.map(stat => stat?.completed || 0)
|
||||||
// 注意:後端可能沒有提供 failed 數據,所以計算或預設為 0
|
// 使用後端提供的failed欄位,如果沒有則預設為0
|
||||||
const failed = dailyStats.value.map(stat => {
|
const failed = dailyStats.value.map(stat => stat?.failed || 0)
|
||||||
if (stat?.failed !== undefined) {
|
|
||||||
return stat.failed
|
|
||||||
}
|
|
||||||
// 如果沒有 failed 數據,可以計算為 total - completed,或預設為 0
|
|
||||||
return Math.max(0, (stat?.jobs || 0) - (stat?.completed || 0))
|
|
||||||
})
|
|
||||||
|
|
||||||
const option = {
|
const option = {
|
||||||
title: {
|
title: {
|
||||||
|
@@ -163,6 +163,12 @@
|
|||||||
>
|
>
|
||||||
重新翻譯
|
重新翻譯
|
||||||
</el-dropdown-item>
|
</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-item command="delete" divided>刪除</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
@@ -251,6 +257,27 @@ const handleJobAction = async (action, job) => {
|
|||||||
}
|
}
|
||||||
break
|
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':
|
case 'delete':
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('確定要刪除此任務嗎?此操作無法撤銷。', '確認刪除', {
|
await ElMessageBox.confirm('確定要刪除此任務嗎?此操作無法撤銷。', '確認刪除', {
|
||||||
|
42
update_db.py
Normal file
42
update_db.py
Normal 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}")
|
Reference in New Issue
Block a user