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:
egg
2025-12-12 17:38:12 +08:00
parent d20751d56b
commit 65abd51d60
21 changed files with 682 additions and 662 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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` (新類型)

View File

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

View File

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

View 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 測試管理員儀表板顯示翻譯統計 (需要實際測試)