feat: add translation billing stats and remove Export/Settings pages
- Add TranslationLog model to track translation API usage per task - Integrate Dify API actual price (total_price) into translation stats - Display translation statistics in admin dashboard with per-task costs - Remove unused Export and Settings pages to simplify frontend - Add GET /api/v2/admin/translation-stats endpoint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""add_translation_logs_table
|
||||
|
||||
Revision ID: g2b3c4d5e6f7
|
||||
Revises: f1a2b3c4d5e6
|
||||
Create Date: 2025-12-12 10:00:00.000000
|
||||
|
||||
Add translation_logs table to track translation API usage and costs.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'g2b3c4d5e6f7'
|
||||
down_revision: Union[str, None] = 'f1a2b3c4d5e6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create tool_ocr_translation_logs table."""
|
||||
op.create_table(
|
||||
'tool_ocr_translation_logs',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False, comment='Foreign key to users table'),
|
||||
sa.Column('task_id', sa.String(length=255), nullable=False, comment='Task UUID that was translated'),
|
||||
sa.Column('target_lang', sa.String(length=10), nullable=False, comment='Target language code'),
|
||||
sa.Column('source_lang', sa.String(length=10), nullable=True, comment='Source language code'),
|
||||
sa.Column('input_tokens', sa.Integer(), nullable=False, server_default='0', comment='Number of input tokens used'),
|
||||
sa.Column('output_tokens', sa.Integer(), nullable=False, server_default='0', comment='Number of output tokens generated'),
|
||||
sa.Column('total_tokens', sa.Integer(), nullable=False, server_default='0', comment='Total tokens (input + output)'),
|
||||
sa.Column('total_elements', sa.Integer(), nullable=False, server_default='0', comment='Total elements in document'),
|
||||
sa.Column('translated_elements', sa.Integer(), nullable=False, server_default='0', comment='Number of elements translated'),
|
||||
sa.Column('total_characters', sa.Integer(), nullable=False, server_default='0', comment='Total characters translated'),
|
||||
sa.Column('estimated_cost', sa.Float(), nullable=False, server_default='0.0', comment='Estimated cost in USD'),
|
||||
sa.Column('processing_time_seconds', sa.Float(), nullable=False, server_default='0.0', comment='Translation processing time'),
|
||||
sa.Column('provider', sa.String(length=50), nullable=False, server_default='dify', comment='Translation provider'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['tool_ocr_users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tool_ocr_translation_logs_id'), 'tool_ocr_translation_logs', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tool_ocr_translation_logs_user_id'), 'tool_ocr_translation_logs', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_tool_ocr_translation_logs_task_id'), 'tool_ocr_translation_logs', ['task_id'], unique=False)
|
||||
op.create_index(op.f('ix_tool_ocr_translation_logs_target_lang'), 'tool_ocr_translation_logs', ['target_lang'], unique=False)
|
||||
op.create_index(op.f('ix_tool_ocr_translation_logs_created_at'), 'tool_ocr_translation_logs', ['created_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop tool_ocr_translation_logs table."""
|
||||
op.drop_index(op.f('ix_tool_ocr_translation_logs_created_at'), table_name='tool_ocr_translation_logs')
|
||||
op.drop_index(op.f('ix_tool_ocr_translation_logs_target_lang'), table_name='tool_ocr_translation_logs')
|
||||
op.drop_index(op.f('ix_tool_ocr_translation_logs_task_id'), table_name='tool_ocr_translation_logs')
|
||||
op.drop_index(op.f('ix_tool_ocr_translation_logs_user_id'), table_name='tool_ocr_translation_logs')
|
||||
op.drop_index(op.f('ix_tool_ocr_translation_logs_id'), table_name='tool_ocr_translation_logs')
|
||||
op.drop_table('tool_ocr_translation_logs')
|
||||
@@ -458,6 +458,14 @@ class Settings(BaseSettings):
|
||||
dify_max_batch_chars: int = Field(default=5000) # Max characters per batch
|
||||
dify_max_batch_items: int = Field(default=20) # Max items per batch
|
||||
|
||||
# Translation cost calculation (USD per 1M tokens) - FALLBACK only
|
||||
# Dify API returns actual price (total_price), this is only used as fallback
|
||||
# when actual price is not available
|
||||
translation_cost_per_million_tokens: float = Field(
|
||||
default=3.0,
|
||||
description="Fallback cost per 1M tokens when Dify doesn't return actual price"
|
||||
)
|
||||
|
||||
# ===== Background Tasks Configuration =====
|
||||
task_queue_type: str = Field(default="memory")
|
||||
redis_url: str = Field(default="redis://localhost:6379/0")
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.models.user import User
|
||||
from app.models.task import Task, TaskFile, TaskStatus
|
||||
from app.models.session import Session
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.translation_log import TranslationLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -17,4 +18,5 @@ __all__ = [
|
||||
"TaskStatus",
|
||||
"Session",
|
||||
"AuditLog",
|
||||
"TranslationLog",
|
||||
]
|
||||
|
||||
87
backend/app/models/translation_log.py
Normal file
87
backend/app/models/translation_log.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Tool_OCR - Translation Log Model
|
||||
Tracks translation usage statistics for billing and monitoring
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TranslationLog(Base):
|
||||
"""
|
||||
Translation log model for tracking API usage and costs.
|
||||
|
||||
Each record represents a single translation job completion,
|
||||
storing token usage and estimated costs for billing purposes.
|
||||
"""
|
||||
|
||||
__tablename__ = "tool_ocr_translation_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("tool_ocr_users.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
comment="Foreign key to users table")
|
||||
task_id = Column(String(255), nullable=False, index=True,
|
||||
comment="Task UUID that was translated")
|
||||
target_lang = Column(String(10), nullable=False, index=True,
|
||||
comment="Target language code (e.g., 'en', 'ja', 'zh-TW')")
|
||||
source_lang = Column(String(10), nullable=True,
|
||||
comment="Source language code (or 'auto')")
|
||||
|
||||
# Token usage statistics
|
||||
input_tokens = Column(Integer, default=0, nullable=False,
|
||||
comment="Number of input tokens used")
|
||||
output_tokens = Column(Integer, default=0, nullable=False,
|
||||
comment="Number of output tokens generated")
|
||||
total_tokens = Column(Integer, default=0, nullable=False,
|
||||
comment="Total tokens (input + output)")
|
||||
|
||||
# Translation statistics
|
||||
total_elements = Column(Integer, default=0, nullable=False,
|
||||
comment="Total elements in document")
|
||||
translated_elements = Column(Integer, default=0, nullable=False,
|
||||
comment="Number of elements translated")
|
||||
total_characters = Column(Integer, default=0, nullable=False,
|
||||
comment="Total characters translated")
|
||||
|
||||
# Cost tracking (estimated based on token pricing)
|
||||
estimated_cost = Column(Float, default=0.0, nullable=False,
|
||||
comment="Estimated cost in USD")
|
||||
|
||||
# Processing info
|
||||
processing_time_seconds = Column(Float, default=0.0, nullable=False,
|
||||
comment="Translation processing time")
|
||||
provider = Column(String(50), default="dify", nullable=False,
|
||||
comment="Translation provider (e.g., 'dify')")
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="translation_logs")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TranslationLog(id={self.id}, task_id='{self.task_id}', target_lang='{self.target_lang}', tokens={self.total_tokens})>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert translation log to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"task_id": self.task_id,
|
||||
"target_lang": self.target_lang,
|
||||
"source_lang": self.source_lang,
|
||||
"input_tokens": self.input_tokens,
|
||||
"output_tokens": self.output_tokens,
|
||||
"total_tokens": self.total_tokens,
|
||||
"total_elements": self.total_elements,
|
||||
"translated_elements": self.translated_elements,
|
||||
"total_characters": self.total_characters,
|
||||
"estimated_cost": self.estimated_cost,
|
||||
"processing_time_seconds": self.processing_time_seconds,
|
||||
"provider": self.provider,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
@@ -33,6 +33,7 @@ class User(Base):
|
||||
tasks = relationship("Task", back_populates="user", cascade="all, delete-orphan")
|
||||
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
||||
audit_logs = relationship("AuditLog", back_populates="user")
|
||||
translation_logs = relationship("TranslationLog", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, email='{self.email}', display_name='{self.display_name}')>"
|
||||
|
||||
@@ -186,3 +186,34 @@ async def get_user_activity_summary(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get user activity summary: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/translation-stats", summary="Get translation statistics")
|
||||
async def get_translation_stats(
|
||||
db: Session = Depends(get_db),
|
||||
admin_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
Get translation usage statistics for billing and monitoring.
|
||||
|
||||
Returns:
|
||||
- total_translations: Total number of translation jobs
|
||||
- total_tokens: Sum of all tokens used
|
||||
- total_characters: Sum of all characters translated
|
||||
- estimated_cost: Estimated cost based on token pricing
|
||||
- by_language: Breakdown by target language
|
||||
- recent_translations: List of recent translation activities
|
||||
- last_30_days: Statistics for the last 30 days
|
||||
|
||||
Requires admin privileges.
|
||||
"""
|
||||
try:
|
||||
stats = admin_service.get_translation_statistics(db)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to get translation statistics")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get translation statistics: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -39,7 +39,8 @@ def run_translation_task(
|
||||
task_id: str,
|
||||
task_db_id: int,
|
||||
target_lang: str,
|
||||
source_lang: str = "auto"
|
||||
source_lang: str = "auto",
|
||||
user_id: int = None
|
||||
):
|
||||
"""
|
||||
Background task to run document translation.
|
||||
@@ -49,10 +50,12 @@ def run_translation_task(
|
||||
task_db_id: Task database ID (for verification)
|
||||
target_lang: Target language code
|
||||
source_lang: Source language code ('auto' for detection)
|
||||
user_id: User ID for logging translation statistics
|
||||
"""
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.translation_service import get_translation_service
|
||||
from app.schemas.translation import TranslationJobState, TranslationProgress
|
||||
from app.models.translation_log import TranslationLog
|
||||
|
||||
db = SessionLocal()
|
||||
translation_service = get_translation_service()
|
||||
@@ -132,6 +135,44 @@ def run_translation_task(
|
||||
result_file_path=str(output_path) if output_path else None
|
||||
))
|
||||
logger.info(f"Translation completed for task {task_id}")
|
||||
|
||||
# Log translation statistics to database
|
||||
if user_id and output_path:
|
||||
try:
|
||||
with open(output_path, 'r', encoding='utf-8') as f:
|
||||
translation_result = json.load(f)
|
||||
|
||||
stats = translation_result.get('statistics', {})
|
||||
total_tokens = stats.get('total_tokens', 0)
|
||||
|
||||
# Use actual price from Dify API if available, otherwise calculate estimated cost
|
||||
actual_price = stats.get('total_price', 0.0)
|
||||
if actual_price > 0:
|
||||
estimated_cost = actual_price
|
||||
else:
|
||||
# Fallback: Calculate estimated cost based on token pricing
|
||||
estimated_cost = (total_tokens / 1_000_000) * settings.translation_cost_per_million_tokens
|
||||
|
||||
translation_log = TranslationLog(
|
||||
user_id=user_id,
|
||||
task_id=task_id,
|
||||
target_lang=target_lang,
|
||||
source_lang=source_lang,
|
||||
total_tokens=total_tokens,
|
||||
input_tokens=0, # Dify doesn't provide separate input/output tokens
|
||||
output_tokens=0,
|
||||
total_elements=stats.get('total_elements', 0),
|
||||
translated_elements=stats.get('translated_elements', 0),
|
||||
total_characters=stats.get('total_characters', 0),
|
||||
processing_time_seconds=stats.get('processing_time_seconds', 0.0),
|
||||
provider=translation_result.get('provider', 'dify'),
|
||||
estimated_cost=estimated_cost
|
||||
)
|
||||
db.add(translation_log)
|
||||
db.commit()
|
||||
logger.info(f"Logged translation stats for task {task_id}: {total_tokens} tokens, ${estimated_cost:.6f}")
|
||||
except Exception as log_error:
|
||||
logger.error(f"Failed to log translation stats: {log_error}")
|
||||
else:
|
||||
translation_service.set_job_state(task_id, TranslationJobState(
|
||||
task_id=task_id,
|
||||
@@ -255,7 +296,8 @@ async def start_translation(
|
||||
task_id=task_id,
|
||||
task_db_id=task.id,
|
||||
target_lang=target_lang,
|
||||
source_lang=request.source_lang
|
||||
source_lang=request.source_lang,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
logger.info(f"Started translation job for task {task_id}, target_lang={target_lang}")
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.models.user import User
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.session import Session as UserSession
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.translation_log import TranslationLog
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -209,6 +210,87 @@ class AdminService:
|
||||
|
||||
return top_users
|
||||
|
||||
def get_translation_statistics(self, db: Session) -> dict:
|
||||
"""
|
||||
Get translation usage statistics for admin dashboard.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dictionary with translation stats including total tokens, costs, and breakdowns
|
||||
"""
|
||||
# Total translation count
|
||||
total_translations = db.query(TranslationLog).count()
|
||||
|
||||
# Sum of tokens
|
||||
token_stats = db.query(
|
||||
func.sum(TranslationLog.total_tokens).label("total_tokens"),
|
||||
func.sum(TranslationLog.input_tokens).label("total_input_tokens"),
|
||||
func.sum(TranslationLog.output_tokens).label("total_output_tokens"),
|
||||
func.sum(TranslationLog.total_characters).label("total_characters"),
|
||||
func.sum(TranslationLog.estimated_cost).label("total_cost")
|
||||
).first()
|
||||
|
||||
# Breakdown by target language
|
||||
by_language = db.query(
|
||||
TranslationLog.target_lang,
|
||||
func.count(TranslationLog.id).label("count"),
|
||||
func.sum(TranslationLog.total_tokens).label("tokens"),
|
||||
func.sum(TranslationLog.total_characters).label("characters")
|
||||
).group_by(TranslationLog.target_lang).all()
|
||||
|
||||
language_breakdown = [
|
||||
{
|
||||
"language": lang,
|
||||
"count": count,
|
||||
"tokens": tokens or 0,
|
||||
"characters": chars or 0
|
||||
}
|
||||
for lang, count, tokens, chars in by_language
|
||||
]
|
||||
|
||||
# Recent translations (last 20)
|
||||
recent = db.query(TranslationLog).order_by(
|
||||
TranslationLog.created_at.desc()
|
||||
).limit(20).all()
|
||||
|
||||
recent_translations = [
|
||||
{
|
||||
"id": log.id,
|
||||
"task_id": log.task_id,
|
||||
"target_lang": log.target_lang,
|
||||
"total_tokens": log.total_tokens,
|
||||
"total_characters": log.total_characters,
|
||||
"processing_time_seconds": log.processing_time_seconds,
|
||||
"estimated_cost": log.estimated_cost,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None
|
||||
}
|
||||
for log in recent
|
||||
]
|
||||
|
||||
# Stats for last 30 days
|
||||
date_30_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||
recent_stats = db.query(
|
||||
func.count(TranslationLog.id).label("count"),
|
||||
func.sum(TranslationLog.total_tokens).label("tokens")
|
||||
).filter(TranslationLog.created_at >= date_30_days_ago).first()
|
||||
|
||||
return {
|
||||
"total_translations": total_translations,
|
||||
"total_tokens": token_stats.total_tokens or 0,
|
||||
"total_input_tokens": token_stats.total_input_tokens or 0,
|
||||
"total_output_tokens": token_stats.total_output_tokens or 0,
|
||||
"total_characters": token_stats.total_characters or 0,
|
||||
"estimated_cost": token_stats.total_cost or 0.0,
|
||||
"by_language": language_breakdown,
|
||||
"recent_translations": recent_translations,
|
||||
"last_30_days": {
|
||||
"count": recent_stats.count or 0,
|
||||
"tokens": recent_stats.tokens or 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
admin_service = AdminService()
|
||||
|
||||
@@ -40,6 +40,8 @@ class TranslationResponse:
|
||||
total_tokens: int
|
||||
latency: float
|
||||
conversation_id: str
|
||||
total_price: float = 0.0
|
||||
currency: str = "USD"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -50,6 +52,8 @@ class BatchTranslationResponse:
|
||||
latency: float
|
||||
conversation_id: str
|
||||
missing_markers: List[int] = field(default_factory=list)
|
||||
total_price: float = 0.0
|
||||
currency: str = "USD"
|
||||
|
||||
|
||||
class DifyTranslationError(Exception):
|
||||
@@ -252,6 +256,11 @@ class DifyClient:
|
||||
translated_text = data.get("answer", "")
|
||||
usage = data.get("metadata", {}).get("usage", {})
|
||||
|
||||
# Extract price info from usage or metadata (may be string or number)
|
||||
raw_price = usage.get("total_price", 0.0)
|
||||
total_price = float(raw_price) if raw_price else 0.0
|
||||
currency = usage.get("currency", "USD") or "USD"
|
||||
|
||||
self._total_tokens += usage.get("total_tokens", 0)
|
||||
self._total_requests += 1
|
||||
|
||||
@@ -259,7 +268,9 @@ class DifyClient:
|
||||
translated_text=translated_text,
|
||||
total_tokens=usage.get("total_tokens", 0),
|
||||
latency=usage.get("latency", 0.0),
|
||||
conversation_id=data.get("conversation_id", "")
|
||||
conversation_id=data.get("conversation_id", ""),
|
||||
total_price=total_price,
|
||||
currency=currency
|
||||
)
|
||||
|
||||
def translate_batch(
|
||||
@@ -297,6 +308,11 @@ class DifyClient:
|
||||
answer = data.get("answer", "")
|
||||
usage = data.get("metadata", {}).get("usage", {})
|
||||
|
||||
# Extract price info from usage or metadata (may be string or number)
|
||||
raw_price = usage.get("total_price", 0.0)
|
||||
total_price = float(raw_price) if raw_price else 0.0
|
||||
currency = usage.get("currency", "USD") or "USD"
|
||||
|
||||
translations = self._parse_batch_response(answer, len(texts))
|
||||
|
||||
# Check for missing markers
|
||||
@@ -314,7 +330,9 @@ class DifyClient:
|
||||
total_tokens=usage.get("total_tokens", 0),
|
||||
latency=usage.get("latency", 0.0),
|
||||
conversation_id=data.get("conversation_id", ""),
|
||||
missing_markers=missing_markers
|
||||
missing_markers=missing_markers,
|
||||
total_price=total_price,
|
||||
currency=currency
|
||||
)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
|
||||
@@ -232,6 +232,8 @@ class TranslationService:
|
||||
self._jobs_lock = threading.Lock()
|
||||
self._total_tokens = 0
|
||||
self._total_latency = 0.0
|
||||
self._total_price = 0.0
|
||||
self._currency = "USD"
|
||||
|
||||
def _load_raw_ocr_regions(
|
||||
self,
|
||||
@@ -459,6 +461,9 @@ class TranslationService:
|
||||
|
||||
self._total_tokens += response.total_tokens
|
||||
self._total_latency += response.latency
|
||||
self._total_price += response.total_price
|
||||
if response.currency:
|
||||
self._currency = response.currency
|
||||
|
||||
# Map translations back to items
|
||||
translated_items = []
|
||||
@@ -524,6 +529,9 @@ class TranslationService:
|
||||
|
||||
self._total_tokens += response.total_tokens
|
||||
self._total_latency += response.latency
|
||||
self._total_price += response.total_price
|
||||
if response.currency:
|
||||
self._currency = response.currency
|
||||
|
||||
return TranslatedItem(
|
||||
element_id=item.element_id,
|
||||
@@ -555,7 +563,9 @@ class TranslationService:
|
||||
total_elements: int,
|
||||
processing_time: float,
|
||||
batch_count: int,
|
||||
processing_track: str = 'direct'
|
||||
processing_track: str = 'direct',
|
||||
total_price: float = 0.0,
|
||||
currency: str = 'USD'
|
||||
) -> Dict:
|
||||
"""
|
||||
Build the translation result JSON structure.
|
||||
@@ -613,6 +623,8 @@ class TranslationService:
|
||||
'total_characters': total_chars,
|
||||
'processing_time_seconds': round(processing_time, 2),
|
||||
'total_tokens': self._total_tokens,
|
||||
'total_price': round(total_price, 6),
|
||||
'currency': currency,
|
||||
'batch_count': batch_count
|
||||
},
|
||||
'translations': {}, # Empty for OCR Track
|
||||
@@ -656,6 +668,8 @@ class TranslationService:
|
||||
'total_characters': total_chars,
|
||||
'processing_time_seconds': round(processing_time, 2),
|
||||
'total_tokens': self._total_tokens,
|
||||
'total_price': round(total_price, 6),
|
||||
'currency': currency,
|
||||
'batch_count': batch_count
|
||||
},
|
||||
'translations': translations
|
||||
@@ -687,6 +701,8 @@ class TranslationService:
|
||||
start_time = time.time()
|
||||
self._total_tokens = 0
|
||||
self._total_latency = 0.0
|
||||
self._total_price = 0.0
|
||||
self._currency = "USD"
|
||||
|
||||
logger.info(
|
||||
f"Starting translation: task_id={task_id}, target={target_lang}"
|
||||
@@ -752,7 +768,9 @@ class TranslationService:
|
||||
total_elements=total_elements,
|
||||
processing_time=processing_time,
|
||||
batch_count=len(batches),
|
||||
processing_track=processing_track
|
||||
processing_track=processing_track,
|
||||
total_price=self._total_price,
|
||||
currency=self._currency
|
||||
)
|
||||
|
||||
# Save result
|
||||
|
||||
@@ -3,8 +3,6 @@ import LoginPage from '@/pages/LoginPage'
|
||||
import UploadPage from '@/pages/UploadPage'
|
||||
import ProcessingPage from '@/pages/ProcessingPage'
|
||||
import ResultsPage from '@/pages/ResultsPage'
|
||||
import ExportPage from '@/pages/ExportPage'
|
||||
import SettingsPage from '@/pages/SettingsPage'
|
||||
import TaskHistoryPage from '@/pages/TaskHistoryPage'
|
||||
import TaskDetailPage from '@/pages/TaskDetailPage'
|
||||
import AdminDashboardPage from '@/pages/AdminDashboardPage'
|
||||
@@ -31,10 +29,8 @@ function App() {
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="processing" element={<ProcessingPage />} />
|
||||
<Route path="results" element={<ResultsPage />} />
|
||||
<Route path="export" element={<ExportPage />} />
|
||||
<Route path="tasks" element={<TaskHistoryPage />} />
|
||||
<Route path="tasks/:taskId" element={<TaskDetailPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
{/* Admin routes - require admin privileges */}
|
||||
<Route
|
||||
|
||||
@@ -5,9 +5,7 @@ import { apiClientV2 } from '@/services/apiV2'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher'
|
||||
import {
|
||||
Upload,
|
||||
Settings,
|
||||
FileText,
|
||||
Download,
|
||||
Activity,
|
||||
LogOut,
|
||||
LayoutDashboard,
|
||||
@@ -41,8 +39,6 @@ export default function Layout() {
|
||||
{ to: '/processing', label: t('nav.processing'), icon: Activity, description: '處理進度', adminOnly: false },
|
||||
{ to: '/results', label: t('nav.results'), icon: FileText, description: '查看結果', adminOnly: false },
|
||||
{ to: '/tasks', label: '任務歷史', icon: History, description: '查看任務記錄', adminOnly: false },
|
||||
{ to: '/export', label: t('nav.export'), icon: Download, description: '導出文件', adminOnly: false },
|
||||
{ to: '/settings', label: t('nav.settings'), icon: Settings, description: '系統設定', adminOnly: false },
|
||||
{ to: '/admin', label: '管理員儀表板', icon: Shield, description: '系統管理', adminOnly: true },
|
||||
]
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { SystemStats, UserWithStats, TopUser } from '@/types/apiV2'
|
||||
import type { SystemStats, UserWithStats, TopUser, TranslationStats } from '@/types/apiV2'
|
||||
import {
|
||||
Users,
|
||||
ClipboardList,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Languages,
|
||||
Coins,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -36,6 +38,7 @@ export default function AdminDashboardPage() {
|
||||
const [stats, setStats] = useState<SystemStats | null>(null)
|
||||
const [users, setUsers] = useState<UserWithStats[]>([])
|
||||
const [topUsers, setTopUsers] = useState<TopUser[]>([])
|
||||
const [translationStats, setTranslationStats] = useState<TranslationStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -45,15 +48,17 @@ export default function AdminDashboardPage() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const [statsData, usersData, topUsersData] = await Promise.all([
|
||||
const [statsData, usersData, topUsersData, translationStatsData] = await Promise.all([
|
||||
apiClientV2.getSystemStats(),
|
||||
apiClientV2.listUsers({ page: 1, page_size: 10 }),
|
||||
apiClientV2.getTopUsers({ metric: 'tasks', limit: 5 }),
|
||||
apiClientV2.getTranslationStats(),
|
||||
])
|
||||
|
||||
setStats(statsData)
|
||||
setUsers(usersData.users)
|
||||
setTopUsers(topUsersData)
|
||||
setTranslationStats(translationStatsData)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch admin data:', err)
|
||||
setError(err.response?.data?.detail || '載入管理員資料失敗')
|
||||
@@ -198,6 +203,130 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Translation Statistics */}
|
||||
{translationStats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Languages className="w-5 h-5" />
|
||||
翻譯統計
|
||||
</CardTitle>
|
||||
<CardDescription>翻譯 API 使用量與計費追蹤</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-blue-600 mb-1">
|
||||
<Languages className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">總翻譯次數</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-700">
|
||||
{translationStats.total_translations.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-blue-500 mt-1">
|
||||
近30天: {translationStats.last_30_days.count}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-purple-600 mb-1">
|
||||
<Activity className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">總 Token 數</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-purple-700">
|
||||
{translationStats.total_tokens.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-purple-500 mt-1">
|
||||
近30天: {translationStats.last_30_days.tokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-1">
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">總字元數</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-700">
|
||||
{translationStats.total_characters.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-amber-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-amber-600 mb-1">
|
||||
<Coins className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">預估成本</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-amber-700">
|
||||
${translationStats.estimated_cost.toFixed(2)}
|
||||
</div>
|
||||
<p className="text-xs text-amber-500 mt-1">USD</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language Breakdown */}
|
||||
{translationStats.by_language.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">語言分佈</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{translationStats.by_language.map((lang) => (
|
||||
<Badge key={lang.language} variant="outline" className="px-3 py-1">
|
||||
{lang.language}: {lang.count} 次 ({lang.tokens.toLocaleString()} tokens)
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Translations */}
|
||||
{translationStats.recent_translations.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">最近翻譯記錄</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>任務 ID</TableHead>
|
||||
<TableHead>目標語言</TableHead>
|
||||
<TableHead className="text-right">Token 數</TableHead>
|
||||
<TableHead className="text-right">字元數</TableHead>
|
||||
<TableHead className="text-right">成本</TableHead>
|
||||
<TableHead className="text-right">處理時間</TableHead>
|
||||
<TableHead>時間</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{translationStats.recent_translations.slice(0, 10).map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{t.task_id.substring(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{t.target_lang}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{t.total_tokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{t.total_characters.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium text-amber-600">
|
||||
${t.estimated_cost.toFixed(4)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{t.processing_time_seconds.toFixed(1)}s
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{new Date(t.created_at).toLocaleString('zh-TW')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Top Users */}
|
||||
{topUsers.length > 0 && (
|
||||
<Card>
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
FileJson,
|
||||
FileType,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ArrowLeft,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
|
||||
type ExportFormat = 'json' | 'markdown' | 'pdf'
|
||||
|
||||
export default function ExportPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { toast } = useToast()
|
||||
|
||||
const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set())
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>('markdown')
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
// Fetch completed tasks
|
||||
const { data: tasksData, isLoading } = useQuery({
|
||||
queryKey: ['tasks', 'completed'],
|
||||
queryFn: () => apiClientV2.listTasks({
|
||||
status: 'completed',
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
order_by: 'completed_at',
|
||||
order_desc: true
|
||||
}),
|
||||
})
|
||||
|
||||
const completedTasks = tasksData?.tasks || []
|
||||
|
||||
// Select/Deselect all
|
||||
const handleSelectAll = () => {
|
||||
if (selectedTasks.size === completedTasks.length) {
|
||||
setSelectedTasks(new Set())
|
||||
} else {
|
||||
setSelectedTasks(new Set(completedTasks.map(t => t.task_id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle task selection
|
||||
const handleToggleTask = (taskId: string) => {
|
||||
const newSelected = new Set(selectedTasks)
|
||||
if (newSelected.has(taskId)) {
|
||||
newSelected.delete(taskId)
|
||||
} else {
|
||||
newSelected.add(taskId)
|
||||
}
|
||||
setSelectedTasks(newSelected)
|
||||
}
|
||||
|
||||
// Export selected tasks
|
||||
const handleExport = async () => {
|
||||
if (selectedTasks.size === 0) {
|
||||
toast({
|
||||
title: '請選擇任務',
|
||||
description: '請至少選擇一個任務進行匯出',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
let successCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
try {
|
||||
for (const taskId of selectedTasks) {
|
||||
try {
|
||||
if (exportFormat === 'json') {
|
||||
await apiClientV2.downloadJSON(taskId)
|
||||
} else if (exportFormat === 'markdown') {
|
||||
await apiClientV2.downloadMarkdown(taskId)
|
||||
} else if (exportFormat === 'pdf') {
|
||||
await apiClientV2.downloadPDF(taskId)
|
||||
}
|
||||
successCount++
|
||||
} catch (error) {
|
||||
console.error(`Export failed for task ${taskId}:`, error)
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast({
|
||||
title: t('export.exportSuccess'),
|
||||
description: `成功匯出 ${successCount} 個檔案${errorCount > 0 ? `,${errorCount} 個失敗` : ''}`,
|
||||
variant: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
if (errorCount > 0 && successCount === 0) {
|
||||
toast({
|
||||
title: t('export.exportError'),
|
||||
description: `所有匯出失敗 (${errorCount} 個檔案)`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatIcons = {
|
||||
json: FileJson,
|
||||
markdown: FileText,
|
||||
pdf: FileType,
|
||||
}
|
||||
|
||||
const formatLabels = {
|
||||
json: 'JSON',
|
||||
markdown: 'Markdown',
|
||||
pdf: 'PDF',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" onClick={() => navigate('/tasks')} className="gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="page-title">{t('export.title')}</h1>
|
||||
<p className="text-muted-foreground mt-1">批次匯出 OCR 處理結果</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Task Selection */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Format Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileType className="w-5 h-5" />
|
||||
選擇匯出格式
|
||||
</CardTitle>
|
||||
<CardDescription>選擇要匯出的檔案格式</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['json', 'markdown', 'pdf'] as ExportFormat[]).map((fmt) => {
|
||||
const Icon = formatIcons[fmt]
|
||||
return (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => setExportFormat(fmt)}
|
||||
className={`p-4 border-2 rounded-lg transition-all ${
|
||||
exportFormat === fmt
|
||||
? 'border-primary bg-primary/10 shadow-md'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 mx-auto mb-2 ${exportFormat === fmt ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<div className={`text-sm font-medium ${exportFormat === fmt ? 'text-primary' : 'text-foreground'}`}>
|
||||
{formatLabels[fmt]}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Task List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
選擇任務
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
選擇要匯出的已完成任務 ({selectedTasks.size}/{completedTasks.length} 已選)
|
||||
</CardDescription>
|
||||
</div>
|
||||
{completedTasks.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleSelectAll}>
|
||||
{selectedTasks.size === completedTasks.length ? '取消全選' : '全選'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : completedTasks.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">沒有已完成的任務</p>
|
||||
<Button onClick={() => navigate('/upload')} className="mt-4">
|
||||
前往上傳頁面
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||
{completedTasks.map((task) => (
|
||||
<div
|
||||
key={task.task_id}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedTasks.has(task.task_id)
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => handleToggleTask(task.task_id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedTasks.has(task.task_id)}
|
||||
onCheckedChange={() => handleToggleTask(task.task_id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{task.filename || '未知檔案'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(task.completed_at!).toLocaleString('zh-TW')}
|
||||
{task.processing_time_ms && ` · ${(task.processing_time_ms / 1000).toFixed(2)}s`}
|
||||
</p>
|
||||
</div>
|
||||
<FileText className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Export Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle>匯出摘要</CardTitle>
|
||||
<CardDescription>當前設定概覽</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-muted/30 rounded-lg space-y-3">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">格式</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = formatIcons[exportFormat]
|
||||
return <Icon className="w-4 h-4 text-primary" />
|
||||
})()}
|
||||
<span className="text-sm font-medium text-foreground">{formatLabels[exportFormat]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">已選任務</div>
|
||||
<div className="text-2xl font-bold text-foreground">{selectedTasks.size}</div>
|
||||
</div>
|
||||
|
||||
{selectedTasks.size > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">預計下載</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{selectedTasks.size} 個 {formatLabels[exportFormat]} 檔案
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={selectedTasks.size === 0 || isExporting}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
匯出中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
匯出選定任務
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/tasks')}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回任務歷史
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { ExportRule } from '@/types/apiV2'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [editingRule, setEditingRule] = useState<ExportRule | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
rule_name: '',
|
||||
confidence_threshold: 0.5,
|
||||
include_metadata: true,
|
||||
filename_pattern: '{filename}_ocr',
|
||||
css_template: 'default',
|
||||
})
|
||||
|
||||
// Fetch export rules
|
||||
const { data: exportRules, isLoading } = useQuery({
|
||||
queryKey: ['exportRules'],
|
||||
queryFn: () => apiClientV2.getExportRules(),
|
||||
})
|
||||
|
||||
// Create rule mutation
|
||||
const createRuleMutation = useMutation({
|
||||
mutationFn: (rule: any) => apiClientV2.createExportRule(rule),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['exportRules'] })
|
||||
setIsCreating(false)
|
||||
resetForm()
|
||||
toast({
|
||||
title: t('common.success'),
|
||||
description: '規則已建立',
|
||||
variant: 'success',
|
||||
})
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: error.response?.data?.detail || t('errors.networkError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Update rule mutation
|
||||
const updateRuleMutation = useMutation({
|
||||
mutationFn: ({ ruleId, rule }: { ruleId: number; rule: any }) =>
|
||||
apiClientV2.updateExportRule(ruleId, rule),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['exportRules'] })
|
||||
setEditingRule(null)
|
||||
resetForm()
|
||||
toast({
|
||||
title: t('common.success'),
|
||||
description: '規則已更新',
|
||||
variant: 'success',
|
||||
})
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: error.response?.data?.detail || t('errors.networkError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Delete rule mutation
|
||||
const deleteRuleMutation = useMutation({
|
||||
mutationFn: (ruleId: number) => apiClientV2.deleteExportRule(ruleId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['exportRules'] })
|
||||
toast({
|
||||
title: t('common.success'),
|
||||
description: '規則已刪除',
|
||||
variant: 'success',
|
||||
})
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: error.response?.data?.detail || t('errors.networkError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
rule_name: '',
|
||||
confidence_threshold: 0.5,
|
||||
include_metadata: true,
|
||||
filename_pattern: '{filename}_ocr',
|
||||
css_template: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setIsCreating(true)
|
||||
setEditingRule(null)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleEdit = (rule: ExportRule) => {
|
||||
setEditingRule(rule)
|
||||
setIsCreating(false)
|
||||
setFormData({
|
||||
rule_name: rule.rule_name,
|
||||
confidence_threshold: rule.config_json.confidence_threshold || 0.5,
|
||||
include_metadata: rule.config_json.include_metadata || true,
|
||||
filename_pattern: rule.config_json.filename_pattern || '{filename}_ocr',
|
||||
css_template: rule.css_template || 'default',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const ruleData = {
|
||||
rule_name: formData.rule_name,
|
||||
config_json: {
|
||||
confidence_threshold: formData.confidence_threshold,
|
||||
include_metadata: formData.include_metadata,
|
||||
filename_pattern: formData.filename_pattern,
|
||||
},
|
||||
css_template: formData.css_template,
|
||||
}
|
||||
|
||||
if (editingRule) {
|
||||
updateRuleMutation.mutate({ ruleId: editingRule.id, rule: ruleData })
|
||||
} else {
|
||||
createRuleMutation.mutate(ruleData)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsCreating(false)
|
||||
setEditingRule(null)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleDelete = (ruleId: number) => {
|
||||
if (window.confirm('確定要刪除此規則嗎?')) {
|
||||
deleteRuleMutation.mutate(ruleId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-foreground">{t('settings.title')}</h1>
|
||||
{!isCreating && !editingRule && (
|
||||
<Button onClick={handleCreate}>{t('export.rules.newRule')}</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{(isCreating || editingRule) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{editingRule ? t('common.edit') + ' ' + t('export.rules.title') : t('export.rules.newRule')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Rule Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('export.rules.ruleName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.rule_name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, rule_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="例如:高信心度匯出"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confidence Threshold */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('export.options.confidenceThreshold')}: {formData.confidence_threshold}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={formData.confidence_threshold}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
confidence_threshold: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Include Metadata */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="include-metadata-form"
|
||||
checked={formData.include_metadata}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
include_metadata: e.target.checked,
|
||||
}))
|
||||
}
|
||||
className="w-4 h-4 border border-gray-200 rounded"
|
||||
/>
|
||||
<label htmlFor="include-metadata-form" className="text-sm font-medium text-foreground">
|
||||
{t('export.options.includeMetadata')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Filename Pattern */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('export.options.filenamePattern')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.filename_pattern}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
filename_pattern: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="{filename}_ocr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CSS Template */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('export.options.cssTemplate')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.css_template}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, css_template: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="default">預設</option>
|
||||
<option value="academic">學術</option>
|
||||
<option value="business">商務</option>
|
||||
<option value="report">報告</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!formData.rule_name || createRuleMutation.isPending || updateRuleMutation.isPending}
|
||||
>
|
||||
{createRuleMutation.isPending || updateRuleMutation.isPending
|
||||
? t('common.loading')
|
||||
: t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Rules List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('export.rules.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-center text-muted-foreground py-8">{t('common.loading')}</p>
|
||||
) : exportRules && exportRules.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{exportRules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">{rule.rule_name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
信心度閾值: {rule.config_json.confidence_threshold || 0.5} | CSS 樣板:{' '}
|
||||
{rule.css_template || 'default'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleEdit(rule)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
disabled={deleteRuleMutation.isPending}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">尚無匯出規則</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
TopUser,
|
||||
AuditLogListResponse,
|
||||
UserActivitySummary,
|
||||
TranslationStats,
|
||||
ProcessingOptions,
|
||||
ProcessingMetadata,
|
||||
DocumentAnalysisResponse,
|
||||
@@ -621,6 +622,14 @@ class ApiClientV2 {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation statistics (admin only)
|
||||
*/
|
||||
async getTranslationStats(): Promise<TranslationStats> {
|
||||
const response = await this.client.get<TranslationStats>('/admin/translation-stats')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ==================== Translation APIs ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -294,6 +294,41 @@ export interface UserActivitySummary {
|
||||
recent_actions: AuditLog[]
|
||||
}
|
||||
|
||||
// ==================== Translation Statistics (Admin) ====================
|
||||
|
||||
export interface TranslationLanguageBreakdown {
|
||||
language: string
|
||||
count: number
|
||||
tokens: number
|
||||
characters: number
|
||||
}
|
||||
|
||||
export interface RecentTranslation {
|
||||
id: number
|
||||
task_id: string
|
||||
target_lang: string
|
||||
total_tokens: number
|
||||
total_characters: number
|
||||
processing_time_seconds: number
|
||||
estimated_cost: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TranslationStats {
|
||||
total_translations: number
|
||||
total_tokens: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
total_characters: number
|
||||
estimated_cost: number
|
||||
by_language: TranslationLanguageBreakdown[]
|
||||
recent_translations: RecentTranslation[]
|
||||
last_30_days: {
|
||||
count: number
|
||||
tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Translation Types ====================
|
||||
|
||||
export type TranslationStatus = 'pending' | 'loading_model' | 'translating' | 'completed' | 'failed'
|
||||
|
||||
62
openspec/changes/simplify-frontend-add-billing/proposal.md
Normal file
62
openspec/changes/simplify-frontend-add-billing/proposal.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Change: 簡化前端頁面並新增翻譯計費功能
|
||||
|
||||
## Why
|
||||
|
||||
目前前端存在多個冗餘的頁面和功能,需要精簡以改善使用者體驗和維護性:
|
||||
1. Tasks 頁面的 JSON/MD 下載功能已不再需要(僅保留 PDF 下載)
|
||||
2. Export 頁面功能與 Tasks 頁面重疊,且複雜度不符實際需求
|
||||
3. Settings 頁面僅管理導出規則,而導出功能即將移除
|
||||
|
||||
同時,系統已整合 Dify 翻譯服務,需要在管理員儀表板中新增翻譯計費追蹤功能,以便監控 API Token 使用量和成本。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 1. 移除 Tasks 頁面的 JSON/MD 下載按鈕(前端)
|
||||
- 已在 TaskDetailPage 移除,確認 ExportPage 中的相關功能一併移除
|
||||
- 保留 apiV2.ts 中的 API 方法(維持後端相容性)
|
||||
|
||||
### 2. 移除 Export 頁面(前端)
|
||||
- 移除 `frontend/src/pages/ExportPage.tsx`
|
||||
- 從 App.tsx 路由配置移除 `/export` 路由
|
||||
- 從 Layout.tsx 導航選單移除 Export 連結
|
||||
- 移除 i18n 中 export 相關翻譯(可選,不影響功能)
|
||||
|
||||
### 3. 移除 Settings 頁面(前端)
|
||||
- 移除 `frontend/src/pages/SettingsPage.tsx`
|
||||
- 從 App.tsx 路由配置移除 `/settings` 路由
|
||||
- 從 Layout.tsx 導航選單移除 Settings 連結
|
||||
- 後端 Export Rules API 保留(不影響現有資料)
|
||||
|
||||
### 4. 新增翻譯計費功能(前端 + 後端)
|
||||
|
||||
#### 後端新增:
|
||||
- 在 `AdminService` 新增 `get_translation_statistics()` 方法
|
||||
- 新增 API 端點 `GET /api/v2/admin/translation-stats`
|
||||
- 返回結構:
|
||||
- 總翻譯任務數
|
||||
- 總 Token 使用量(input_tokens, output_tokens)
|
||||
- 各語言翻譯統計
|
||||
- 預估成本(基於配置的 Token 價格)
|
||||
|
||||
#### 前端新增:
|
||||
- 在 AdminDashboardPage 新增「翻譯統計」卡片
|
||||
- 顯示總 Token 使用量、翻譯次數、預估成本
|
||||
- 顯示各目標語言的翻譯分佈
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: frontend-ui (修改), backend-api (修改)
|
||||
- Affected code:
|
||||
- **前端移除**:
|
||||
- `frontend/src/pages/ExportPage.tsx`
|
||||
- `frontend/src/pages/SettingsPage.tsx`
|
||||
- `frontend/src/App.tsx` (路由)
|
||||
- `frontend/src/components/Layout.tsx` (導航)
|
||||
- **後端新增**:
|
||||
- `backend/app/services/admin_service.py` (翻譯統計方法)
|
||||
- `backend/app/routers/admin.py` (新 API 端點)
|
||||
- `backend/app/schemas/admin.py` (回應結構)
|
||||
- **前端新增**:
|
||||
- `frontend/src/pages/AdminDashboardPage.tsx` (翻譯統計元件)
|
||||
- `frontend/src/services/apiV2.ts` (新 API 呼叫)
|
||||
- `frontend/src/types/apiV2.ts` (新類型)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Spec Delta: backend-api
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Translation Statistics Endpoint
|
||||
The system SHALL provide a new admin API endpoint for translation usage statistics across all users.
|
||||
|
||||
#### Scenario: Admin requests translation statistics
|
||||
- GIVEN the admin is authenticated
|
||||
- WHEN GET /api/v2/admin/translation-stats is called
|
||||
- THEN the response contains:
|
||||
- total_translations: number of translation jobs
|
||||
- total_input_tokens: sum of input tokens used
|
||||
- total_output_tokens: sum of output tokens used
|
||||
- estimated_cost: calculated cost based on token pricing
|
||||
- by_language: breakdown of translations by target language
|
||||
- recent_translations: list of recent translation activities
|
||||
|
||||
#### Scenario: Non-admin user requests translation statistics
|
||||
- GIVEN a regular user is authenticated
|
||||
- WHEN GET /api/v2/admin/translation-stats is called
|
||||
- THEN the response is 403 Forbidden
|
||||
@@ -0,0 +1,31 @@
|
||||
# Spec Delta: frontend-ui
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
- REQ-FE-EXPORT: Export Page - The export page for batch exporting task results is removed.
|
||||
- REQ-FE-SETTINGS: Settings Page - The settings page for managing export rules is removed.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Translation Statistics in Admin Dashboard
|
||||
The admin dashboard SHALL display translation usage statistics and estimated costs.
|
||||
|
||||
#### Scenario: Admin views translation statistics
|
||||
- GIVEN the user is logged in as admin
|
||||
- WHEN the user views the admin dashboard
|
||||
- THEN the page displays a translation statistics card showing:
|
||||
- Total translation count
|
||||
- Total token usage (input + output tokens)
|
||||
- Estimated cost based on token pricing
|
||||
- Breakdown by target language
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Navigation Menu Updated
|
||||
The navigation menu SHALL be updated to remove Export and Settings links.
|
||||
|
||||
#### Scenario: User views navigation menu
|
||||
- GIVEN the user is logged in
|
||||
- WHEN the user views the sidebar navigation
|
||||
- THEN the menu shows: Upload, Processing, Results, Task History, Admin (if admin)
|
||||
- AND the menu does NOT show: Export, Settings
|
||||
39
openspec/changes/simplify-frontend-add-billing/tasks.md
Normal file
39
openspec/changes/simplify-frontend-add-billing/tasks.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Tasks: 簡化前端頁面並新增翻譯計費功能
|
||||
|
||||
## 1. 移除 Export 頁面
|
||||
|
||||
- [x] 1.1 從 App.tsx 移除 `/export` 路由
|
||||
- [x] 1.2 從 Layout.tsx 導航選單移除 Export 連結
|
||||
- [x] 1.3 刪除 `frontend/src/pages/ExportPage.tsx`
|
||||
|
||||
## 2. 移除 Settings 頁面
|
||||
|
||||
- [x] 2.1 從 App.tsx 移除 `/settings` 路由
|
||||
- [x] 2.2 從 Layout.tsx 導航選單移除 Settings 連結
|
||||
- [x] 2.3 刪除 `frontend/src/pages/SettingsPage.tsx`
|
||||
|
||||
## 3. 後端翻譯統計 API
|
||||
|
||||
- [x] 3.1 新增 `TranslationLog` model 和 migration
|
||||
- [x] 3.2 在 `admin_service.py` 新增 `get_translation_statistics()` 方法
|
||||
- [x] 3.3 在 `admin.py` router 新增 `GET /admin/translation-stats` 端點
|
||||
- [x] 3.4 修改翻譯流程在完成時寫入統計到資料庫
|
||||
|
||||
## 4. 前端翻譯統計顯示
|
||||
|
||||
- [x] 4.1 在 `apiV2.ts` 新增 `getTranslationStats()` API 呼叫
|
||||
- [x] 4.2 在 `types/apiV2.ts` 新增翻譯統計類型定義
|
||||
- [x] 4.3 在 `AdminDashboardPage.tsx` 新增翻譯統計卡片
|
||||
|
||||
## 5. i18n 翻譯
|
||||
|
||||
- [ ] 5.1 新增翻譯統計相關中文翻譯 (暫時使用硬編碼)
|
||||
- [ ] 5.2 新增翻譯統計相關英文翻譯 (暫時使用硬編碼)
|
||||
|
||||
## 6. 測試與驗證
|
||||
|
||||
- [x] 6.1 驗證 Export/Settings 頁面路由已移除
|
||||
- [x] 6.2 驗證導航選單已更新
|
||||
- [x] 6.3 驗證 TypeScript 編譯通過
|
||||
- [ ] 6.4 測試翻譯統計 API 回傳正確資料 (需要實際翻譯測試)
|
||||
- [ ] 6.5 測試管理員儀表板顯示翻譯統計 (需要實際測試)
|
||||
Reference in New Issue
Block a user