This commit is contained in:
parent
144f5793aa
commit
4269fdec26
|
|
@ -323,8 +323,11 @@ TRADINGAGENTS_RESULTS_DIR=./results
|
||||||
##### 2.4 啟動後端服務
|
##### 2.4 啟動後端服務
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 從專案根目錄執行
|
# 從專案根目錄執行(開發模式,啟用 hot reload)
|
||||||
python -m backend
|
python -m backend
|
||||||
|
|
||||||
|
# 生產模式(停用 hot reload,避免任務丟失)
|
||||||
|
python -m backend --reload false
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ 後端服務成功啟動後,您可以訪問:
|
✅ 後端服務成功啟動後,您可以訪問:
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ WORKDIR /app
|
||||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
# Copy application code from backend directory
|
# Copy application code from backend directory (including __main__.py for python -m)
|
||||||
COPY backend/app ./backend/app
|
COPY backend ./backend
|
||||||
|
|
||||||
# Copy tradingagents package from project root
|
# Copy tradingagents package from project root
|
||||||
COPY tradingagents ./tradingagents
|
COPY tradingagents ./tradingagents
|
||||||
|
|
@ -40,4 +40,5 @@ EXPOSE 8000
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
# Run the application using the PORT environment variable (required by Railway)
|
# Run the application using the PORT environment variable (required by Railway)
|
||||||
CMD ["sh", "-c", "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
|
# Using --reload false to prevent hot-reload issues in production
|
||||||
|
CMD ["sh", "-c", "python -m backend --reload false --port ${PORT:-8000}"]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
"""
|
"""
|
||||||
Backend module entry point
|
Backend module entry point
|
||||||
Run with: python -m backend
|
Run with: python -m backend
|
||||||
|
Options:
|
||||||
|
--reload / --reload false Enable/disable hot reload (default: true for dev)
|
||||||
|
--port PORT Server port (default: 8000)
|
||||||
|
--host HOST Server host (default: 0.0.0.0)
|
||||||
"""
|
"""
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add parent directory to path to import tradingagents
|
# Add parent directory to path to import tradingagents
|
||||||
|
|
@ -13,25 +18,49 @@ if parent_dir not in sys.path:
|
||||||
sys.path.insert(0, parent_dir)
|
sys.path.insert(0, parent_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
"""Parse command line arguments"""
|
||||||
|
parser = argparse.ArgumentParser(description="TradingAgentsX Backend Server")
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
type=str,
|
||||||
|
default=os.getenv("BACKEND_HOST", "0.0.0.0"),
|
||||||
|
help="Server host (default: 0.0.0.0)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=int(os.getenv("PORT", os.getenv("BACKEND_PORT", "8000"))),
|
||||||
|
help="Server port (default: 8000)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reload",
|
||||||
|
type=str,
|
||||||
|
default=os.getenv("BACKEND_RELOAD", "true"),
|
||||||
|
help="Enable hot reload (true/false, default: true)"
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Start the FastAPI server"""
|
"""Start the FastAPI server"""
|
||||||
# Get host and port from environment variables with defaults
|
args = parse_args()
|
||||||
host = os.getenv("BACKEND_HOST", "0.0.0.0")
|
|
||||||
port = int(os.getenv("BACKEND_PORT", "8000"))
|
# Parse reload flag (support both boolean-style and string)
|
||||||
reload = os.getenv("BACKEND_RELOAD", "true").lower() == "true"
|
reload = args.reload.lower() in ("true", "1", "yes") if isinstance(args.reload, str) else args.reload
|
||||||
|
|
||||||
print(f"🚀 Starting TradingAgentsX Backend Server...")
|
print(f"🚀 Starting TradingAgentsX Backend Server...")
|
||||||
print(f"📍 Host: {host}")
|
print(f"📍 Host: {args.host}")
|
||||||
print(f"🔌 Port: {port}")
|
print(f"🔌 Port: {args.port}")
|
||||||
print(f"🔄 Reload: {reload}")
|
print(f"🔄 Reload: {reload}")
|
||||||
print(f"\n📖 API Documentation: http://localhost:{port}/docs")
|
print(f"\n📖 API Documentation: http://localhost:{args.port}/docs")
|
||||||
print(f"📊 Health Check: http://localhost:{port}/api/health\n")
|
print(f"📊 Health Check: http://localhost:{args.port}/api/health\n")
|
||||||
|
|
||||||
# Start uvicorn server
|
# Start uvicorn server
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"backend.app.main:app", # Use full module path
|
"backend.app.main:app", # Use full module path
|
||||||
host=host,
|
host=args.host,
|
||||||
port=port,
|
port=args.port,
|
||||||
reload=reload,
|
reload=reload,
|
||||||
log_level="info",
|
log_level="info",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import type { AnalysisRequest } from "@/lib/types";
|
||||||
|
|
||||||
export default function AnalysisPage() {
|
export default function AnalysisPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setAnalysisResult, setTaskId } = useAnalysisContext();
|
const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext();
|
||||||
const { runAnalysis, loading, error, result, taskId } = useAnalysis();
|
const { runAnalysis, loading, error, result, taskId } = useAnalysis();
|
||||||
|
|
||||||
// 當分析完成時自動跳轉到結果頁面
|
// 當分析完成時自動跳轉到結果頁面
|
||||||
|
|
@ -30,6 +30,10 @@ export default function AnalysisPage() {
|
||||||
|
|
||||||
const handleSubmit = async (data: AnalysisRequest) => {
|
const handleSubmit = async (data: AnalysisRequest) => {
|
||||||
try {
|
try {
|
||||||
|
// Store the market type for later use when saving the report
|
||||||
|
if (data.market_type) {
|
||||||
|
setMarketType(data.market_type);
|
||||||
|
}
|
||||||
await runAnalysis(data);
|
await runAnalysis(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error is handled by the hook
|
// Error is handled by the hook
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ import { DownloadReports } from "@/components/analysis/DownloadReports";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { ChevronLeft, Save, Check, AlertCircle } from "lucide-react";
|
||||||
|
import { saveReport, checkDuplicateReport } from "@/lib/reports-db";
|
||||||
|
|
||||||
const ANALYSTS = [
|
const ANALYSTS = [
|
||||||
// === 分析師團隊 ===
|
// === 分析師團隊 ===
|
||||||
|
|
@ -101,9 +102,14 @@ const getNestedValue = (obj: any, path: string) => {
|
||||||
|
|
||||||
export default function AnalysisResultsPage() {
|
export default function AnalysisResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { analysisResult, taskId } = useAnalysisContext();
|
const { analysisResult, taskId, marketType } = useAnalysisContext();
|
||||||
const [selectedAnalyst, setSelectedAnalyst] = useState("market");
|
const [selectedAnalyst, setSelectedAnalyst] = useState("market");
|
||||||
|
|
||||||
|
// Save report states
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 如果沒有結果,重定向到分析頁面
|
// 如果沒有結果,重定向到分析頁面
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!analysisResult) {
|
if (!analysisResult) {
|
||||||
|
|
@ -111,6 +117,46 @@ export default function AnalysisResultsPage() {
|
||||||
}
|
}
|
||||||
}, [analysisResult, router]);
|
}, [analysisResult, router]);
|
||||||
|
|
||||||
|
// Handle save report
|
||||||
|
const handleSaveReport = async () => {
|
||||||
|
if (!analysisResult) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check for duplicate
|
||||||
|
const duplicate = await checkDuplicateReport(
|
||||||
|
analysisResult.ticker,
|
||||||
|
analysisResult.analysis_date
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
setSaveError("此報告已存在(相同股票代碼與分析日期)");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveReport(
|
||||||
|
analysisResult.ticker,
|
||||||
|
marketType,
|
||||||
|
analysisResult.analysis_date,
|
||||||
|
analysisResult,
|
||||||
|
taskId || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
setSaveSuccess(true);
|
||||||
|
// Reset success message after 3 seconds
|
||||||
|
setTimeout(() => setSaveSuccess(false), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Save report error:", error);
|
||||||
|
setSaveError("儲存失敗,請稍後再試");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!analysisResult) {
|
if (!analysisResult) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12">
|
||||||
|
|
@ -143,14 +189,52 @@ export default function AnalysisResultsPage() {
|
||||||
分析日期:{analysisResult.analysis_date}
|
分析日期:{analysisResult.analysis_date}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex gap-2 items-center">
|
||||||
variant="outline"
|
{/* Save success/error feedback */}
|
||||||
onClick={() => router.push("/analysis")}
|
{saveSuccess && (
|
||||||
className="gap-2 hover-lift"
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-sm animate-fade-in">
|
||||||
>
|
<Check className="h-4 w-4" />
|
||||||
<ChevronLeft className="h-4 w-4" />
|
已儲存
|
||||||
返回分析
|
</span>
|
||||||
</Button>
|
)}
|
||||||
|
{saveError && (
|
||||||
|
<span className="flex items-center gap-1 text-red-500 text-sm animate-fade-in">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
{saveError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Report Button */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleSaveReport}
|
||||||
|
disabled={saving || saveSuccess}
|
||||||
|
className="gap-2 hover-lift bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>儲存中...</>
|
||||||
|
) : saveSuccess ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
已儲存
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
儲存報告
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/analysis")}
|
||||||
|
className="gap-2 hover-lift"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
返回分析
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分析師選擇 Tabs */}
|
{/* 分析師選擇 Tabs */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,371 @@
|
||||||
|
/**
|
||||||
|
* History page - browse saved analysis reports
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { zhTW } from "date-fns/locale";
|
||||||
|
import { useAnalysisContext } from "@/context/AnalysisContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Trash2, Eye, RefreshCw, TrendingUp } from "lucide-react";
|
||||||
|
import {
|
||||||
|
getReportsByMarketType,
|
||||||
|
deleteReport,
|
||||||
|
getReportCountByMarketType,
|
||||||
|
type SavedReport,
|
||||||
|
} from "@/lib/reports-db";
|
||||||
|
|
||||||
|
// Market type labels
|
||||||
|
const MARKET_LABELS = {
|
||||||
|
us: { label: "🇺🇸 美股", description: "美國股市分析報告" },
|
||||||
|
twse: { label: "🇹🇼 上市", description: "台灣證券交易所上市股票" },
|
||||||
|
tpex: { label: "🇹🇼 上櫃/興櫃", description: "台灣櫃買中心上櫃/興櫃股票" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to extract decision from trader report
|
||||||
|
const extractDecisionFromReport = (report: SavedReport): { action: string; color: string } => {
|
||||||
|
// First try the decision.action field
|
||||||
|
if (report.result.decision?.action) {
|
||||||
|
const action = report.result.decision.action;
|
||||||
|
const actionLower = action.toLowerCase();
|
||||||
|
const color = actionLower.includes("buy")
|
||||||
|
? "text-green-600"
|
||||||
|
: actionLower.includes("sell")
|
||||||
|
? "text-red-600"
|
||||||
|
: "text-yellow-600";
|
||||||
|
return { action, color };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to extract from trader_investment_plan
|
||||||
|
const traderReport = report.result.reports?.trader_investment_plan;
|
||||||
|
if (traderReport) {
|
||||||
|
const text = traderReport.toLowerCase();
|
||||||
|
|
||||||
|
// Look for Chinese decision keywords first
|
||||||
|
if (text.includes("最終交易提案:買入") || text.includes("建議:買入")) {
|
||||||
|
return { action: "買入", color: "text-green-600" };
|
||||||
|
}
|
||||||
|
if (text.includes("最終交易提案:賣出") || text.includes("建議:賣出")) {
|
||||||
|
return { action: "賣出", color: "text-red-600" };
|
||||||
|
}
|
||||||
|
if (text.includes("最終交易提案:持有") || text.includes("建議:持有")) {
|
||||||
|
return { action: "持有", color: "text-yellow-600" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for English keywords
|
||||||
|
if (text.includes("buy") && !text.includes("sell")) {
|
||||||
|
return { action: "買入 (BUY)", color: "text-green-600" };
|
||||||
|
}
|
||||||
|
if (text.includes("sell") && !text.includes("buy")) {
|
||||||
|
return { action: "賣出 (SELL)", color: "text-red-600" };
|
||||||
|
}
|
||||||
|
if (text.includes("hold")) {
|
||||||
|
return { action: "持有 (HOLD)", color: "text-yellow-600" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try final_trade_decision
|
||||||
|
const finalDecision = report.result.reports?.final_trade_decision;
|
||||||
|
if (finalDecision) {
|
||||||
|
const text = finalDecision.toLowerCase();
|
||||||
|
if (text.includes("buy") || text.includes("買入")) {
|
||||||
|
return { action: "買入", color: "text-green-600" };
|
||||||
|
}
|
||||||
|
if (text.includes("sell") || text.includes("賣出")) {
|
||||||
|
return { action: "賣出", color: "text-red-600" };
|
||||||
|
}
|
||||||
|
if (text.includes("hold") || text.includes("持有")) {
|
||||||
|
return { action: "持有", color: "text-yellow-600" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "N/A", color: "text-gray-500" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<"us" | "twse" | "tpex">("us");
|
||||||
|
const [reports, setReports] = useState<SavedReport[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [counts, setCounts] = useState({ us: 0, twse: 0, tpex: 0 });
|
||||||
|
|
||||||
|
// Delete confirmation dialog
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [reportToDelete, setReportToDelete] = useState<SavedReport | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Load reports when tab changes
|
||||||
|
useEffect(() => {
|
||||||
|
loadReports();
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
// Load counts on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadCounts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadReports = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getReportsByMarketType(activeTab);
|
||||||
|
setReports(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load reports:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCounts = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getReportCountByMarketType();
|
||||||
|
setCounts(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load counts:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewReport = (report: SavedReport) => {
|
||||||
|
// Set the context with the saved report data
|
||||||
|
setAnalysisResult(report.result);
|
||||||
|
setTaskId(report.task_id || null);
|
||||||
|
setMarketType(report.market_type);
|
||||||
|
// Navigate to results page
|
||||||
|
router.push("/analysis/results");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (report: SavedReport) => {
|
||||||
|
setReportToDelete(report);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!reportToDelete?.id) return;
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteReport(reportToDelete.id);
|
||||||
|
// Refresh reports and counts
|
||||||
|
await loadReports();
|
||||||
|
await loadCounts();
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setReportToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete report:", error);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-purple-50/30 via-pink-50/20 to-purple-50/30 dark:from-gray-950 dark:via-purple-950/40 dark:to-gray-950">
|
||||||
|
<div className="container mx-auto px-4 py-12">
|
||||||
|
<div className="max-w-6xl mx-auto space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center relative animate-fade-in">
|
||||||
|
<div className="absolute inset-0 gradient-bg-radial opacity-40 -z-10" />
|
||||||
|
<h1 className="text-4xl font-bold mb-2 gradient-text-primary">
|
||||||
|
歷史報告
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
瀏覽已儲存的分析報告
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Market Type Tabs */}
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
|
||||||
|
className="w-full animate-slide-up animate-delay-200"
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-3 h-auto gap-2">
|
||||||
|
{(Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>).map(
|
||||||
|
(key) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={key}
|
||||||
|
value={key}
|
||||||
|
className="py-3 text-base transition-all duration-300 hover:scale-105"
|
||||||
|
>
|
||||||
|
<span className="mr-2">{MARKET_LABELS[key].label}</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-white/20 text-xs">
|
||||||
|
{counts[key]}
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{(Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>).map(
|
||||||
|
(marketType) => (
|
||||||
|
<TabsContent key={marketType} value={marketType} className="mt-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Refresh button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadReports}
|
||||||
|
disabled={loading}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
重新整理
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Report List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin mx-auto text-gray-400" />
|
||||||
|
<p className="text-gray-500 mt-4">載入中...</p>
|
||||||
|
</div>
|
||||||
|
) : reports.length === 0 ? (
|
||||||
|
<Card className="animate-fade-in">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
尚無{MARKET_LABELS[marketType].label}的分析報告
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2">
|
||||||
|
執行分析後,可在結果頁面儲存報告
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => router.push("/analysis")}
|
||||||
|
>
|
||||||
|
開始分析
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{reports.map((report) => (
|
||||||
|
<Card
|
||||||
|
key={report.id}
|
||||||
|
className="hover-lift animate-scale-up transition-all duration-300"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span className="text-xl font-bold gradient-text-primary">
|
||||||
|
{report.ticker}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-gradient-to-r from-blue-100 to-pink-100 dark:from-blue-900 dark:to-purple-900 text-gray-600 dark:text-gray-300">
|
||||||
|
{MARKET_LABELS[report.market_type].label}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
分析日期:{report.analysis_date}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
儲存時間:
|
||||||
|
{format(
|
||||||
|
new Date(report.saved_at),
|
||||||
|
"yyyy/MM/dd HH:mm",
|
||||||
|
{ locale: zhTW }
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{(() => {
|
||||||
|
const decision = extractDecisionFromReport(report);
|
||||||
|
return (
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
<span className="font-medium">決策:</span>
|
||||||
|
<span className={`ml-1 font-semibold ${decision.color}`}>
|
||||||
|
{decision.action}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 gap-1"
|
||||||
|
onClick={() => handleViewReport(report)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
檢視
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1"
|
||||||
|
onClick={() => handleDeleteClick(report)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
刪除
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>確認刪除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
確定要刪除 <strong>{reportToDelete?.ticker}</strong> 於{" "}
|
||||||
|
<strong>{reportToDelete?.analysis_date}</strong> 的分析報告嗎?
|
||||||
|
<br />
|
||||||
|
<span className="text-red-500">此操作無法復原。</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? "刪除中..." : "確認刪除"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,12 @@ export function Header() {
|
||||||
>
|
>
|
||||||
分析
|
分析
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/history"
|
||||||
|
className="hover:opacity-80 transition-opacity font-medium"
|
||||||
|
>
|
||||||
|
歷史報告
|
||||||
|
</Link>
|
||||||
<ApiSettingsDialog />
|
<ApiSettingsDialog />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ interface AnalysisContextType {
|
||||||
setAnalysisResult: (result: AnalysisResponse | null) => void;
|
setAnalysisResult: (result: AnalysisResponse | null) => void;
|
||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
setTaskId: (taskId: string | null) => void;
|
setTaskId: (taskId: string | null) => void;
|
||||||
|
marketType: "us" | "twse" | "tpex";
|
||||||
|
setMarketType: (type: "us" | "twse" | "tpex") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnalysisContext = createContext<AnalysisContextType | undefined>(
|
const AnalysisContext = createContext<AnalysisContextType | undefined>(
|
||||||
|
|
@ -19,9 +21,17 @@ export function AnalysisProvider({ children }: { children: ReactNode }) {
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [taskId, setTaskId] = useState<string | null>(null);
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
|
const [marketType, setMarketType] = useState<"us" | "twse" | "tpex">("us");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnalysisContext.Provider value={{ analysisResult, setAnalysisResult, taskId, setTaskId }}>
|
<AnalysisContext.Provider value={{
|
||||||
|
analysisResult,
|
||||||
|
setAnalysisResult,
|
||||||
|
taskId,
|
||||||
|
setTaskId,
|
||||||
|
marketType,
|
||||||
|
setMarketType,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AnalysisContext.Provider>
|
</AnalysisContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
/**
|
||||||
|
* IndexedDB database for storing analysis reports
|
||||||
|
* Uses Dexie.js for a cleaner API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Dexie, { type Table } from 'dexie';
|
||||||
|
import type { AnalysisResponse } from './types';
|
||||||
|
|
||||||
|
// Saved report interface
|
||||||
|
export interface SavedReport {
|
||||||
|
id?: number; // Auto-generated primary key
|
||||||
|
ticker: string; // Stock ticker symbol
|
||||||
|
market_type: "us" | "twse" | "tpex"; // Market type
|
||||||
|
analysis_date: string; // Analysis date (YYYY-MM-DD)
|
||||||
|
saved_at: Date; // Save timestamp
|
||||||
|
task_id?: string; // Original task ID
|
||||||
|
result: AnalysisResponse; // Full analysis result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database class extending Dexie
|
||||||
|
class ReportsDatabase extends Dexie {
|
||||||
|
reports!: Table<SavedReport>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('TradingAgentsReports');
|
||||||
|
this.version(1).stores({
|
||||||
|
// Define indexes: ++id = auto-increment, others are indexed fields
|
||||||
|
reports: '++id, ticker, market_type, analysis_date, saved_at'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database singleton instance
|
||||||
|
const db = new ReportsDatabase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a report to the database
|
||||||
|
*/
|
||||||
|
export async function saveReport(
|
||||||
|
ticker: string,
|
||||||
|
market_type: "us" | "twse" | "tpex",
|
||||||
|
analysis_date: string,
|
||||||
|
result: AnalysisResponse,
|
||||||
|
task_id?: string
|
||||||
|
): Promise<number> {
|
||||||
|
const report: SavedReport = {
|
||||||
|
ticker,
|
||||||
|
market_type,
|
||||||
|
analysis_date,
|
||||||
|
saved_at: new Date(),
|
||||||
|
task_id,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await db.reports.add(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all reports by market type
|
||||||
|
*/
|
||||||
|
export async function getReportsByMarketType(
|
||||||
|
market_type: "us" | "twse" | "tpex"
|
||||||
|
): Promise<SavedReport[]> {
|
||||||
|
return await db.reports
|
||||||
|
.where('market_type')
|
||||||
|
.equals(market_type)
|
||||||
|
.reverse()
|
||||||
|
.sortBy('saved_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all saved reports, sorted by saved_at descending
|
||||||
|
*/
|
||||||
|
export async function getAllReports(): Promise<SavedReport[]> {
|
||||||
|
return await db.reports
|
||||||
|
.orderBy('saved_at')
|
||||||
|
.reverse()
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single report by ID
|
||||||
|
*/
|
||||||
|
export async function getReportById(id: number): Promise<SavedReport | undefined> {
|
||||||
|
return await db.reports.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a report by ID
|
||||||
|
*/
|
||||||
|
export async function deleteReport(id: number): Promise<void> {
|
||||||
|
await db.reports.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple reports by IDs
|
||||||
|
*/
|
||||||
|
export async function deleteReports(ids: number[]): Promise<void> {
|
||||||
|
await db.reports.bulkDelete(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get report count by market type
|
||||||
|
*/
|
||||||
|
export async function getReportCountByMarketType(): Promise<{
|
||||||
|
us: number;
|
||||||
|
twse: number;
|
||||||
|
tpex: number;
|
||||||
|
}> {
|
||||||
|
const [us, twse, tpex] = await Promise.all([
|
||||||
|
db.reports.where('market_type').equals('us').count(),
|
||||||
|
db.reports.where('market_type').equals('twse').count(),
|
||||||
|
db.reports.where('market_type').equals('tpex').count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { us, twse, tpex };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a report with the same ticker and analysis_date already exists
|
||||||
|
*/
|
||||||
|
export async function checkDuplicateReport(
|
||||||
|
ticker: string,
|
||||||
|
analysis_date: string
|
||||||
|
): Promise<SavedReport | undefined> {
|
||||||
|
return await db.reports
|
||||||
|
.where('ticker')
|
||||||
|
.equals(ticker)
|
||||||
|
.and(report => report.analysis_date === analysis_date)
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the db instance for advanced usage
|
||||||
|
export { db };
|
||||||
|
|
@ -47,6 +47,7 @@ export interface AnalysisResponse {
|
||||||
status: string;
|
status: string;
|
||||||
ticker: string;
|
ticker: string;
|
||||||
analysis_date: string;
|
analysis_date: string;
|
||||||
|
market_type?: "us" | "twse" | "tpex"; // 市場類型:美股、上市、上櫃/興櫃
|
||||||
decision?: any;
|
decision?: any;
|
||||||
reports?: any;
|
reports?: any;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dexie": "^4.2.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "16.0.7",
|
"next": "16.0.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue