From 65abd51d60393e2d77ac8c46a2b074fcc89952d2 Mon Sep 17 00:00:00 2001 From: egg Date: Fri, 12 Dec 2025 17:38:12 +0800 Subject: [PATCH] feat: add translation billing stats and remove Export/Settings pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ...g2b3c4d5e6f7_add_translation_logs_table.py | 58 ++++ backend/app/core/config.py | 8 + backend/app/models/__init__.py | 2 + backend/app/models/translation_log.py | 87 +++++ backend/app/models/user.py | 1 + backend/app/routers/admin.py | 31 ++ backend/app/routers/translate.py | 46 ++- backend/app/services/admin_service.py | 82 +++++ backend/app/services/dify_client.py | 22 +- backend/app/services/translation_service.py | 22 +- frontend/src/App.tsx | 4 - frontend/src/components/Layout.tsx | 4 - frontend/src/pages/AdminDashboardPage.tsx | 133 ++++++- frontend/src/pages/ExportPage.tsx | 321 ----------------- frontend/src/pages/SettingsPage.tsx | 325 ------------------ frontend/src/services/apiV2.ts | 9 + frontend/src/types/apiV2.ts | 35 ++ .../simplify-frontend-add-billing/proposal.md | 62 ++++ .../specs/backend-api/spec.md | 22 ++ .../specs/frontend-ui/spec.md | 31 ++ .../simplify-frontend-add-billing/tasks.md | 39 +++ 21 files changed, 682 insertions(+), 662 deletions(-) create mode 100644 backend/alembic/versions/g2b3c4d5e6f7_add_translation_logs_table.py create mode 100644 backend/app/models/translation_log.py delete mode 100644 frontend/src/pages/ExportPage.tsx delete mode 100644 frontend/src/pages/SettingsPage.tsx create mode 100644 openspec/changes/simplify-frontend-add-billing/proposal.md create mode 100644 openspec/changes/simplify-frontend-add-billing/specs/backend-api/spec.md create mode 100644 openspec/changes/simplify-frontend-add-billing/specs/frontend-ui/spec.md create mode 100644 openspec/changes/simplify-frontend-add-billing/tasks.md diff --git a/backend/alembic/versions/g2b3c4d5e6f7_add_translation_logs_table.py b/backend/alembic/versions/g2b3c4d5e6f7_add_translation_logs_table.py new file mode 100644 index 0000000..5b87340 --- /dev/null +++ b/backend/alembic/versions/g2b3c4d5e6f7_add_translation_logs_table.py @@ -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') diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d115761..0840c3f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 57e45e3..9bba8b2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/translation_log.py b/backend/app/models/translation_log.py new file mode 100644 index 0000000..927ebbf --- /dev/null +++ b/backend/app/models/translation_log.py @@ -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"" + + 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 + } diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f29269e..6cfe205 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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"" diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index e343e88..0ff94b9 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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)}" + ) diff --git a/backend/app/routers/translate.py b/backend/app/routers/translate.py index dbe60d8..bbe9e12 100644 --- a/backend/app/routers/translate.py +++ b/backend/app/routers/translate.py @@ -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}") diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 933114c..e94e2dc 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -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() diff --git a/backend/app/services/dify_client.py b/backend/app/services/dify_client.py index 64726c6..57f5004 100644 --- a/backend/app/services/dify_client.py +++ b/backend/app/services/dify_client.py @@ -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: diff --git a/backend/app/services/translation_service.py b/backend/app/services/translation_service.py index bb777e3..c7df9a3 100644 --- a/backend/app/services/translation_service.py +++ b/backend/app/services/translation_service.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae987ba..9df4b93 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> - } /> } /> } /> - } /> {/* Admin routes - require admin privileges */} (null) const [users, setUsers] = useState([]) const [topUsers, setTopUsers] = useState([]) + const [translationStats, setTranslationStats] = useState(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() { )} + {/* Translation Statistics */} + {translationStats && ( + + + + + 翻譯統計 + + 翻譯 API 使用量與計費追蹤 + + +
+
+
+ + 總翻譯次數 +
+
+ {translationStats.total_translations.toLocaleString()} +
+

+ 近30天: {translationStats.last_30_days.count} +

+
+ +
+
+ + 總 Token 數 +
+
+ {translationStats.total_tokens.toLocaleString()} +
+

+ 近30天: {translationStats.last_30_days.tokens.toLocaleString()} +

+
+ +
+
+ + 總字元數 +
+
+ {translationStats.total_characters.toLocaleString()} +
+
+ +
+
+ + 預估成本 +
+
+ ${translationStats.estimated_cost.toFixed(2)} +
+

USD

+
+
+ + {/* Language Breakdown */} + {translationStats.by_language.length > 0 && ( +
+

語言分佈

+
+ {translationStats.by_language.map((lang) => ( + + {lang.language}: {lang.count} 次 ({lang.tokens.toLocaleString()} tokens) + + ))} +
+
+ )} + + {/* Recent Translations */} + {translationStats.recent_translations.length > 0 && ( +
+

最近翻譯記錄

+ + + + 任務 ID + 目標語言 + Token 數 + 字元數 + 成本 + 處理時間 + 時間 + + + + {translationStats.recent_translations.slice(0, 10).map((t) => ( + + + {t.task_id.substring(0, 8)}... + + + {t.target_lang} + + + {t.total_tokens.toLocaleString()} + + + {t.total_characters.toLocaleString()} + + + ${t.estimated_cost.toFixed(4)} + + + {t.processing_time_seconds.toFixed(1)}s + + + {new Date(t.created_at).toLocaleString('zh-TW')} + + + ))} + +
+
+ )} +
+
+ )} + {/* Top Users */} {topUsers.length > 0 && ( diff --git a/frontend/src/pages/ExportPage.tsx b/frontend/src/pages/ExportPage.tsx deleted file mode 100644 index a7f2ca4..0000000 --- a/frontend/src/pages/ExportPage.tsx +++ /dev/null @@ -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>(new Set()) - const [exportFormat, setExportFormat] = useState('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 ( -
- {/* Page Header */} -
-
-
- -
-

{t('export.title')}

-

批次匯出 OCR 處理結果

-
-
-
-
- -
- {/* Left Column - Task Selection */} -
- {/* Format Selection */} - - - - - 選擇匯出格式 - - 選擇要匯出的檔案格式 - - -
- {(['json', 'markdown', 'pdf'] as ExportFormat[]).map((fmt) => { - const Icon = formatIcons[fmt] - return ( - - ) - })} -
-
-
- - {/* Task List */} - - -
-
- - - 選擇任務 - - - 選擇要匯出的已完成任務 ({selectedTasks.size}/{completedTasks.length} 已選) - -
- {completedTasks.length > 0 && ( - - )} -
-
- - {isLoading ? ( -
- -
- ) : completedTasks.length === 0 ? ( -
- -

沒有已完成的任務

- -
- ) : ( -
- {completedTasks.map((task) => ( -
handleToggleTask(task.task_id)} - > - handleToggleTask(task.task_id)} - onClick={(e) => e.stopPropagation()} - /> -
-

{task.filename || '未知檔案'}

-

- {new Date(task.completed_at!).toLocaleString('zh-TW')} - {task.processing_time_ms && ` · ${(task.processing_time_ms / 1000).toFixed(2)}s`} -

-
- -
- ))} -
- )} -
-
-
- - {/* Right Column - Export Summary */} -
- - - 匯出摘要 - 當前設定概覽 - - -
-
-
格式
-
- {(() => { - const Icon = formatIcons[exportFormat] - return - })()} - {formatLabels[exportFormat]} -
-
- -
-
已選任務
-
{selectedTasks.size}
-
- - {selectedTasks.size > 0 && ( -
-
預計下載
-
- {selectedTasks.size} 個 {formatLabels[exportFormat]} 檔案 -
-
- )} -
- -
- - - -
-
-
-
-
-
- ) -} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx deleted file mode 100644 index 4b686bb..0000000 --- a/frontend/src/pages/SettingsPage.tsx +++ /dev/null @@ -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(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 ( -
-
-

{t('settings.title')}

- {!isCreating && !editingRule && ( - - )} -
- - {/* Create/Edit Form */} - {(isCreating || editingRule) && ( - - - - {editingRule ? t('common.edit') + ' ' + t('export.rules.title') : t('export.rules.newRule')} - - - - {/* Rule Name */} -
- - 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="例如:高信心度匯出" - /> -
- - {/* Confidence Threshold */} -
- - - setFormData((prev) => ({ - ...prev, - confidence_threshold: Number(e.target.value), - })) - } - className="w-full" - /> -
- - {/* Include Metadata */} -
- - setFormData((prev) => ({ - ...prev, - include_metadata: e.target.checked, - })) - } - className="w-4 h-4 border border-gray-200 rounded" - /> - -
- - {/* Filename Pattern */} -
- - - 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" - /> -
- - {/* CSS Template */} -
- - -
- - {/* Actions */} -
- - -
-
-
- )} - - {/* Rules List */} - - - {t('export.rules.title')} - - - {isLoading ? ( -

{t('common.loading')}

- ) : exportRules && exportRules.length > 0 ? ( -
- {exportRules.map((rule) => ( -
-
-

{rule.rule_name}

-

- 信心度閾值: {rule.config_json.confidence_threshold || 0.5} | CSS 樣板:{' '} - {rule.css_template || 'default'} -

-
-
- - -
-
- ))} -
- ) : ( -

尚無匯出規則

- )} -
-
-
- ) -} diff --git a/frontend/src/services/apiV2.ts b/frontend/src/services/apiV2.ts index 4a19a61..3266714 100644 --- a/frontend/src/services/apiV2.ts +++ b/frontend/src/services/apiV2.ts @@ -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 { + const response = await this.client.get('/admin/translation-stats') + return response.data + } + // ==================== Translation APIs ==================== /** diff --git a/frontend/src/types/apiV2.ts b/frontend/src/types/apiV2.ts index 6397258..bf92689 100644 --- a/frontend/src/types/apiV2.ts +++ b/frontend/src/types/apiV2.ts @@ -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' diff --git a/openspec/changes/simplify-frontend-add-billing/proposal.md b/openspec/changes/simplify-frontend-add-billing/proposal.md new file mode 100644 index 0000000..f6a903b --- /dev/null +++ b/openspec/changes/simplify-frontend-add-billing/proposal.md @@ -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` (新類型) diff --git a/openspec/changes/simplify-frontend-add-billing/specs/backend-api/spec.md b/openspec/changes/simplify-frontend-add-billing/specs/backend-api/spec.md new file mode 100644 index 0000000..b57c16d --- /dev/null +++ b/openspec/changes/simplify-frontend-add-billing/specs/backend-api/spec.md @@ -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 diff --git a/openspec/changes/simplify-frontend-add-billing/specs/frontend-ui/spec.md b/openspec/changes/simplify-frontend-add-billing/specs/frontend-ui/spec.md new file mode 100644 index 0000000..db95276 --- /dev/null +++ b/openspec/changes/simplify-frontend-add-billing/specs/frontend-ui/spec.md @@ -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 diff --git a/openspec/changes/simplify-frontend-add-billing/tasks.md b/openspec/changes/simplify-frontend-add-billing/tasks.md new file mode 100644 index 0000000..d1b2e00 --- /dev/null +++ b/openspec/changes/simplify-frontend-add-billing/tasks.md @@ -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 測試管理員儀表板顯示翻譯統計 (需要實際測試)