feat: 新增彭博数据侧边栏功能,并优化历史记录视图以按公司分组显示会话。
This commit is contained in:
parent
d7b6015c9a
commit
43c21b7658
93
backend/scripts/migrate_stock_codes.py
Normal file
93
backend/scripts/migrate_stock_codes.py
Normal file
@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
from sqlalchemy import select, update
|
||||
|
||||
# Add parent directory to path to import app modules
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import LLMUsageLog, Company
|
||||
|
||||
async def migrate():
|
||||
async with SessionLocal() as session:
|
||||
print("🚀 Starting migration of stock codes in LLMUsageLogs...")
|
||||
|
||||
# 1. Fetch all companies to build a lookup map
|
||||
# Map: symbol -> { name, market }
|
||||
# Note: Symbols might not be unique across markets (e.g. same code in diff markets?),
|
||||
# but for now we assume symbol allows us to find the main entry or we try to best match.
|
||||
# To be safe, we might need to be smart about extracting symbol.
|
||||
|
||||
print("📦 Fetching company metadata...")
|
||||
result = await session.execute(select(Company))
|
||||
companies = result.scalars().all()
|
||||
|
||||
# Create lookup: symbol -> Company
|
||||
# If multiple markets have same symbol, this simple map might be ambiguous.
|
||||
# We'll use the first one found or maybe we can improve logic if needed.
|
||||
company_map = {c.symbol: c for c in companies}
|
||||
|
||||
print(f"✅ Loaded {len(company_map)} companies.")
|
||||
|
||||
# 2. Fetch all logs
|
||||
print("📜 Fetching chat logs...")
|
||||
result = await session.execute(select(LLMUsageLog))
|
||||
logs = result.scalars().all()
|
||||
|
||||
print(f"✅ Found {len(logs)} logs. Processing...")
|
||||
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for log in logs:
|
||||
if not log.stock_code:
|
||||
continue
|
||||
|
||||
original_code = log.stock_code
|
||||
symbol = None
|
||||
|
||||
# Pattern 1: "Name (Symbol)" e.g. "金龙鱼 (300999)"
|
||||
match1 = re.search(r'\((.*?)\)', original_code)
|
||||
|
||||
# Pattern 2: "Symbol" e.g. "300999" or "AAPL"
|
||||
# If no brackets, assume the whole string is the symbol (trimmed)
|
||||
|
||||
if match1:
|
||||
# Extract content inside brackets
|
||||
content = match1.group(1)
|
||||
# Check if it already has market info inside brackets e.g. "300999 CH" (space separated)
|
||||
parts = content.split()
|
||||
symbol = parts[0]
|
||||
else:
|
||||
# No brackets, assume it is just the symbol
|
||||
symbol = original_code.strip()
|
||||
|
||||
# Lookup
|
||||
if symbol in company_map:
|
||||
company = company_map[symbol]
|
||||
|
||||
# Format: "Name (Symbol Market)"
|
||||
new_code = f"{company.company_name} ({company.symbol} {company.market})"
|
||||
|
||||
if new_code != original_code:
|
||||
log.stock_code = new_code
|
||||
updated_count += 1
|
||||
# print(f" 🔄 Updating: '{original_code}' -> '{new_code}'")
|
||||
else:
|
||||
# print(f" ⚠️ Symbol '{symbol}' not found in companies table. Skipping '{original_code}'.")
|
||||
skipped_count += 1
|
||||
|
||||
# 3. Commit changes
|
||||
if updated_count > 0:
|
||||
print(f"💾 Committing {updated_count} updates to database...")
|
||||
await session.commit()
|
||||
print("✅ Database updated successfully.")
|
||||
else:
|
||||
print("✨ No updates needed.")
|
||||
|
||||
print(f"Done. Updated: {updated_count}, Skipped/Unchanged: {skipped_count}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(migrate())
|
||||
@ -13,7 +13,7 @@ import { AnalysisReport } from "@/components/analysis-report"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeft, Building2, RefreshCw, Loader2, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { ArrowLeft, Building2, RefreshCw, Loader2, CheckCircle2, ChevronLeft, ChevronRight, TrendingUp, PanelRightClose } from "lucide-react"
|
||||
import type { SearchResult } from "@/lib/types"
|
||||
import { useFinancialData } from "@/hooks/use-financial-data"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
@ -24,6 +24,7 @@ import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { StockChart } from "@/components/stock-chart"
|
||||
import { AiDiscussionView } from "@/components/ai-discussion-view"
|
||||
import { HistoryView } from "@/components/history-view"
|
||||
import { SidePanelBloomberg } from "@/components/side-panel-bloomberg"
|
||||
|
||||
function HomeInner() {
|
||||
const searchParams = useSearchParams()
|
||||
@ -32,6 +33,7 @@ function HomeInner() {
|
||||
const [selectedCompany, setSelectedCompany] = useState<SearchResult | null>(null)
|
||||
const [selectedDataSource, setSelectedDataSource] = useState<string>("iFinD")
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(false)
|
||||
const [companyId, setCompanyId] = useState<number | null>(null)
|
||||
const [analysisId, setAnalysisId] = useState<number | null>(null)
|
||||
const [oneTimeModel, setOneTimeModel] = useState<string | undefined>(undefined)
|
||||
@ -49,6 +51,8 @@ function HomeInner() {
|
||||
|
||||
// Switch to financial view by default when company is selected
|
||||
setCurrentView("financial")
|
||||
// Close right sidebar on new selection
|
||||
setIsRightSidebarOpen(false)
|
||||
|
||||
// 如果没有传入数据源,则根据市场设置默认值
|
||||
const targetDataSource = dataSource || (company.market === 'CN' ? 'Tushare' : 'iFinD')
|
||||
@ -204,7 +208,7 @@ function HomeInner() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full bg-background overflow-hidden">
|
||||
<div className="flex h-screen w-full bg-background overflow-hidden relative">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
@ -233,11 +237,36 @@ function HomeInner() {
|
||||
{isSidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
{/* Right Sidebar Toggle Button */}
|
||||
{selectedCompany && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"absolute right-0 top-1/2 -translate-y-1/2 z-20 h-12 w-4 p-0 rounded-r-none rounded-l-lg border border-r-0 shadow-sm hover:w-8 transition-all opacity-50 hover:opacity-100 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 text-blue-600 dark:text-blue-400",
|
||||
isRightSidebarOpen && "right-[800px]"
|
||||
)}
|
||||
onClick={() => setIsRightSidebarOpen(!isRightSidebarOpen)}
|
||||
title="Bloomberg Financial Data"
|
||||
>
|
||||
{isRightSidebarOpen ? <PanelRightClose className="h-4 w-4" /> : <TrendingUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
{renderContent()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar Component */}
|
||||
{selectedCompany && (
|
||||
<SidePanelBloomberg
|
||||
company={selectedCompany}
|
||||
isOpen={isRightSidebarOpen}
|
||||
onClose={() => setIsRightSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -205,7 +205,7 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
|
||||
system_prompt: systemPrompt,
|
||||
use_google_search: useGoogleSearch,
|
||||
session_id: sessionId,
|
||||
stock_code: `${companyName} (${symbol})`
|
||||
stock_code: `${companyName} (${symbol} ${market})`
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -7,8 +7,11 @@ export const HeaderPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
return () => setMounted(false)
|
||||
const timer = setTimeout(() => setMounted(true), 0)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
setMounted(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
@ -26,13 +26,21 @@ interface GroupedSession {
|
||||
logs: LogEntry[]
|
||||
}
|
||||
|
||||
interface GroupedCompany {
|
||||
stock_code: string
|
||||
latest_timestamp: string
|
||||
sessions: GroupedSession[]
|
||||
}
|
||||
|
||||
export function HistoryView() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [groupedSessions, setGroupedSessions] = useState<GroupedSession[]>([])
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
const [groupedCompanies, setGroupedCompanies] = useState<GroupedCompany[]>([])
|
||||
// Initial selection state
|
||||
const [selection, setSelection] = useState<{ type: 'company' | 'session', id: string } | null>(null)
|
||||
const [expandedCompanies, setExpandedCompanies] = useState<Set<string>>(new Set())
|
||||
const [filterText, setFilterText] = useState("")
|
||||
|
||||
// 多选模式状态
|
||||
// Multi-select mode state
|
||||
const [isSelectMode, setIsSelectMode] = useState(false)
|
||||
const [selectedForExport, setSelectedForExport] = useState<Set<string>>(new Set())
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
@ -43,52 +51,75 @@ export function HistoryView() {
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
// Use relative path to leverage Next.js proxy if configured, or assume same origin
|
||||
// If dev environment needs full URL, use environment variable or fallback
|
||||
const res = await fetch("/api/chat/history")
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch history:", res.status, res.statusText)
|
||||
console.error("Failed to fetch history:", res.status)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (!Array.isArray(data)) return
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
console.error("History data is not an array:", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Group by session_id
|
||||
const groups: { [key: string]: GroupedSession } = {}
|
||||
// 1. Group by Stock Code first
|
||||
const companyGroups: { [code: string]: { code: string, lastTimestamp: string, sessions: { [sid: string]: GroupedSession } } } = {}
|
||||
|
||||
data.forEach((log: LogEntry) => {
|
||||
const sid = log.session_id || "unknown"
|
||||
if (!groups[sid]) {
|
||||
groups[sid] = {
|
||||
session_id: sid,
|
||||
stock_code: log.stock_code || "Unknown",
|
||||
const stockCode = log.stock_code || "Unknown"
|
||||
const sessionId = log.session_id || "unknown"
|
||||
|
||||
if (!companyGroups[stockCode]) {
|
||||
companyGroups[stockCode] = {
|
||||
code: stockCode,
|
||||
lastTimestamp: log.timestamp,
|
||||
sessions: {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!companyGroups[stockCode].sessions[sessionId]) {
|
||||
companyGroups[stockCode].sessions[sessionId] = {
|
||||
session_id: sessionId,
|
||||
stock_code: stockCode,
|
||||
timestamp: log.timestamp,
|
||||
logs: []
|
||||
}
|
||||
}
|
||||
groups[sid].logs.push(log)
|
||||
|
||||
companyGroups[stockCode].sessions[sessionId].logs.push(log)
|
||||
|
||||
// Update company last timestamp if this log is newer
|
||||
if (new Date(log.timestamp) > new Date(companyGroups[stockCode].lastTimestamp)) {
|
||||
companyGroups[stockCode].lastTimestamp = log.timestamp
|
||||
}
|
||||
// Update session timestamp if this log is newer (though usually logs come in order or we sort later)
|
||||
if (new Date(log.timestamp) > new Date(companyGroups[stockCode].sessions[sessionId].timestamp)) {
|
||||
companyGroups[stockCode].sessions[sessionId].timestamp = log.timestamp
|
||||
}
|
||||
})
|
||||
|
||||
// Sort logs within each group by timestamp (ascending) for display
|
||||
Object.values(groups).forEach(group => {
|
||||
group.logs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
})
|
||||
// 2. Convert to arrays and sort
|
||||
const sortedCompanies: GroupedCompany[] = Object.values(companyGroups).map(company => {
|
||||
// Sort sessions within company: newest first
|
||||
const sessions = Object.values(company.sessions).map(session => {
|
||||
// Sort logs within session: oldest first (chronological)
|
||||
session.logs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
return session
|
||||
}).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
|
||||
// Convert to array and sort by timestamp
|
||||
const sortedGroups = Object.values(groups).sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
)
|
||||
return {
|
||||
stock_code: company.code,
|
||||
latest_timestamp: company.lastTimestamp,
|
||||
sessions: sessions
|
||||
}
|
||||
}).sort((a, b) => new Date(b.latest_timestamp).getTime() - new Date(a.latest_timestamp).getTime())
|
||||
|
||||
setGroupedSessions(sortedGroups)
|
||||
if (sortedGroups.length > 0) {
|
||||
setSelectedSessionId(sortedGroups[0].session_id)
|
||||
setGroupedCompanies(sortedCompanies)
|
||||
|
||||
// Select the first company by default if nothing is selected
|
||||
if (sortedCompanies.length > 0 && !selection) {
|
||||
setSelection({ type: 'company', id: sortedCompanies[0].stock_code })
|
||||
setExpandedCompanies(new Set([sortedCompanies[0].stock_code]))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch history:", error)
|
||||
} finally {
|
||||
@ -96,12 +127,97 @@ export function HistoryView() {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedSession = groupedSessions.find(g => g.session_id === selectedSessionId)
|
||||
const toggleCompanyExpansion = (stockCode: string, e?: React.MouseEvent) => {
|
||||
if (e) e.stopPropagation()
|
||||
setExpandedCompanies(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(stockCode)) {
|
||||
next.delete(stockCode)
|
||||
} else {
|
||||
next.add(stockCode)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectCompany = (stockCode: string) => {
|
||||
if (isSelectMode) return
|
||||
setSelection({ type: 'company', id: stockCode })
|
||||
// Also ensure it is expanded
|
||||
setExpandedCompanies(prev => new Set(prev).add(stockCode))
|
||||
}
|
||||
|
||||
const handleSelectSession = (sessionId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isSelectMode) {
|
||||
toggleSessionExportSelection(sessionId)
|
||||
} else {
|
||||
setSelection({ type: 'session', id: sessionId })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSessionExportSelection = (sessionId: string) => {
|
||||
setSelectedForExport(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sessionId)) next.delete(sessionId)
|
||||
else next.add(sessionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Prepare logs for display based on selection
|
||||
const getDisplayLogs = () => {
|
||||
if (!selection) return []
|
||||
|
||||
if (selection.type === 'session') {
|
||||
for (const company of groupedCompanies) {
|
||||
const session = company.sessions.find(s => s.session_id === selection.id)
|
||||
if (session) return session.logs
|
||||
}
|
||||
} else if (selection.type === 'company') {
|
||||
const company = groupedCompanies.find(c => c.stock_code === selection.id)
|
||||
if (company) {
|
||||
// Flatten all logs from all sessions
|
||||
const allLogs = company.sessions.flatMap(s => s.logs)
|
||||
// Sort by timestamp chronological
|
||||
return allLogs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getDisplayTitle = () => {
|
||||
if (!selection) return ""
|
||||
if (selection.type === 'company') return selection.id
|
||||
|
||||
// If session, find the stock code
|
||||
for (const company of groupedCompanies) {
|
||||
if (company.sessions.some(s => s.session_id === selection.id)) {
|
||||
return company.stock_code
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const getDisplaySubtitle = () => {
|
||||
if (!selection) return ""
|
||||
if (selection.type === 'company') return "所有讨论记录"
|
||||
return selection.id
|
||||
}
|
||||
|
||||
const parseDate = (dateString: string) => {
|
||||
// Backend returns UTC time without 'Z' (e.g. 2026-01-17T02:31:46.022901)
|
||||
// We must append 'Z' so the browser parses it as UTC and converts to local time correctly
|
||||
let safeDateString = dateString
|
||||
if (dateString && dateString.includes('T') && !dateString.endsWith('Z') && !dateString.includes('+')) {
|
||||
safeDateString += 'Z'
|
||||
}
|
||||
return new Date(safeDateString)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
const date = parseDate(dateString)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
@ -111,7 +227,7 @@ export function HistoryView() {
|
||||
}
|
||||
|
||||
const formatFullDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
const date = parseDate(dateString)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
@ -123,23 +239,9 @@ export function HistoryView() {
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// 切换选择某个 session
|
||||
const toggleSessionSelection = (sessionId: string) => {
|
||||
setSelectedForExport(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId)
|
||||
} else {
|
||||
next.add(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// 导出 PDF
|
||||
// Export PDF logic (reused mostly)
|
||||
const handleExportPDF = async () => {
|
||||
if (selectedForExport.size === 0) return
|
||||
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const response = await fetch("/api/chat/export-pdf", {
|
||||
@ -147,11 +249,7 @@ export function HistoryView() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_ids: Array.from(selectedForExport) })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("导出失败")
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error("导出失败")
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
@ -161,31 +259,34 @@ export function HistoryView() {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
// 导出成功后退出多选模式
|
||||
setIsSelectMode(false)
|
||||
setSelectedForExport(new Set())
|
||||
} catch (error) {
|
||||
console.error("导出 PDF 失败:", error)
|
||||
alert("导出 PDF 失败,请稍后重试")
|
||||
alert("导出 PDF 失败")
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const displayLogs = getDisplayLogs()
|
||||
const displayTitle = getDisplayTitle()
|
||||
const displaySubtitle = getDisplaySubtitle()
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center items-center h-full"><Loader2 className="animate-spin" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-4 p-4 overflow-hidden">
|
||||
{/* Left Sidebar: Session List */}
|
||||
<div className="w-[280px] flex-shrink-0 flex flex-col gap-2 h-full">
|
||||
{/* Left Sidebar: Tree List */}
|
||||
<div className="w-[300px] flex-shrink-0 flex flex-col gap-2 h-full">
|
||||
{/* Tools Header */}
|
||||
<div className="flex items-center gap-2 mb-4 flex-shrink-0">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="筛选代码..."
|
||||
placeholder="筛选公司..."
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
className="pl-8 h-9"
|
||||
@ -204,111 +305,114 @@ export function HistoryView() {
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* 多选模式工具栏 */}
|
||||
|
||||
{isSelectMode && (
|
||||
<div className="flex items-center gap-2 mb-2 p-2 bg-muted/30 rounded-md flex-shrink-0">
|
||||
<span className="text-sm text-muted-foreground flex-1">
|
||||
已选择 {selectedForExport.size} 个会话
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsSelectMode(false)
|
||||
setSelectedForExport(new Set())
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
取消
|
||||
<span className="text-sm text-muted-foreground flex-1">已选 {selectedForExport.size}</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setIsSelectMode(false); setSelectedForExport(new Set()) }}>
|
||||
<X className="h-4 w-4 mr-1" />取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selectedForExport.size === 0 || isExporting}
|
||||
onClick={handleExportPDF}
|
||||
>
|
||||
<Button size="sm" disabled={selectedForExport.size === 0 || isExporting} onClick={handleExportPDF}>
|
||||
{isExporting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <FileDown className="h-4 w-4 mr-1" />}
|
||||
导出 PDF
|
||||
PDF
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-1 custom-scrollbar">
|
||||
{groupedSessions
|
||||
.filter(group =>
|
||||
group.stock_code.toLowerCase().includes(filterText.toLowerCase())
|
||||
)
|
||||
.map(group => (
|
||||
<div
|
||||
key={group.session_id}
|
||||
className={`
|
||||
cursor-pointer px-3 py-2 rounded-md text-sm transition-colors flex items-center gap-2
|
||||
${selectedForExport.has(group.session_id) ? 'bg-primary/10 border border-primary/30' : ''}
|
||||
${!isSelectMode && selectedSessionId === group.session_id ? 'bg-secondary font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}
|
||||
`}
|
||||
onClick={() => {
|
||||
if (isSelectMode) {
|
||||
toggleSessionSelection(group.session_id)
|
||||
} else {
|
||||
setSelectedSessionId(group.session_id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSelectMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedForExport.has(group.session_id)}
|
||||
onChange={() => toggleSessionSelection(group.session_id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<span className="font-mono font-medium">{group.stock_code}</span>
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDate(group.timestamp)}
|
||||
</span>
|
||||
{groupedCompanies
|
||||
.filter(c => c.stock_code.toLowerCase().includes(filterText.toLowerCase()))
|
||||
.map(company => {
|
||||
const isCompanySelected = selection?.type === 'company' && selection.id === company.stock_code
|
||||
const isExpanded = expandedCompanies.has(company.stock_code)
|
||||
|
||||
return (
|
||||
<div key={company.stock_code} className="mb-1">
|
||||
{/* Company Node */}
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer transition-colors
|
||||
${isCompanySelected && !isSelectMode ? 'bg-secondary font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}
|
||||
`}
|
||||
onClick={() => handleSelectCompany(company.stock_code)}
|
||||
>
|
||||
{/* Expand Toggle */}
|
||||
<div
|
||||
className="p-0.5 rounded-sm hover:bg-muted/80"
|
||||
onClick={(e) => toggleCompanyExpansion(company.stock_code, e)}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<span className="font-medium text-sm">{company.stock_code}</span>
|
||||
<span className="text-xs opacity-70 scale-90">{formatDate(company.latest_timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Children */}
|
||||
{isExpanded && (
|
||||
<div className="pl-4 mt-1 space-y-0.5 border-l-2 border-muted ml-3">
|
||||
{company.sessions.map(session => {
|
||||
const isSessionSelected = selection?.type === 'session' && selection.id === session.session_id
|
||||
return (
|
||||
<div
|
||||
key={session.session_id}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-1.5 rounded-md text-xs cursor-pointer transition-colors
|
||||
${selectedForExport.has(session.session_id) ? 'bg-primary/10 border-primary/30' : ''}
|
||||
${isSessionSelected && !isSelectMode ? 'bg-secondary/80 text-foreground font-medium' : 'text-muted-foreground hover:bg-muted/50'}
|
||||
`}
|
||||
onClick={(e) => handleSelectSession(session.session_id, e)}
|
||||
>
|
||||
{isSelectMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedForExport.has(session.session_id)}
|
||||
readOnly
|
||||
className="h-3 w-3 rounded border-gray-300 text-primary focus:ring-primary mr-1"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 truncate">
|
||||
会话 {formatDate(session.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content: Chat Details */}
|
||||
{/* Right Content */}
|
||||
<div className="flex-1 flex flex-col h-full bg-card rounded-lg border shadow-sm overflow-hidden">
|
||||
{selectedSession ? (
|
||||
{selection ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b bg-muted/20 flex justify-between items-center flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-bold flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
{selectedSession.stock_code}
|
||||
<h3 className="font-bold flex items-center gap-2 text-lg">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
{displayTitle}
|
||||
</h3>
|
||||
<Badge variant="outline">{selectedSession.session_id}</Badge>
|
||||
<Badge variant="outline" className="font-mono">{displaySubtitle}</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatFullDate(selectedSession.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 scroll-smooth">
|
||||
<div className="space-y-8 max-w-4xl mx-auto pb-8">
|
||||
{selectedSession.logs.map((log, idx) => (
|
||||
{displayLogs.map((log, idx) => (
|
||||
<div key={log.id} className="space-y-4">
|
||||
{/* User Message - Hidden as requested */}
|
||||
{/*
|
||||
<div className="flex gap-4">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-bold">U</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="font-semibold text-sm">User</div>
|
||||
<div className="bg-muted/50 p-4 rounded-lg text-sm whitespace-pre-wrap">
|
||||
{parsePrompt(log.prompt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
*/}
|
||||
|
||||
{/* Response / Model Message */}
|
||||
<div className="flex gap-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-bold text-blue-500">AI</span>
|
||||
@ -316,14 +420,12 @@ export function HistoryView() {
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm">Model ({log.model})</span>
|
||||
<span className="text-xs text-muted-foreground">Tokens: {log.total_tokens}</span>
|
||||
{log.used_google_search ? (
|
||||
<Badge variant="secondary" className="text-[10px] bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100 hover:bg-green-100">
|
||||
Google Search: ON
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||
Google Search: OFF
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(log.timestamp)}
|
||||
</span>
|
||||
{log.used_google_search && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-green-100 text-green-800">
|
||||
Search: ON
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@ -331,21 +433,13 @@ export function HistoryView() {
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node, children, ...props }) => <p className="mb-2 last:mb-0" {...props}>{children}</p>,
|
||||
strong: ({ node, children, ...props }) => <strong className="font-bold text-foreground" {...props}>{children}</strong>,
|
||||
em: ({ node, children, ...props }) => <em className="italic" {...props}>{children}</em>,
|
||||
a: ({ node, href, children, ...props }) => <a href={href} className="text-blue-500 hover:underline" target="_blank" rel="noopener noreferrer" {...props}>{children}</a>,
|
||||
ul: ({ node, children, ...props }) => <ul className="list-disc list-inside mb-2" {...props}>{children}</ul>,
|
||||
ol: ({ node, children, ...props }) => <ol className="list-decimal list-inside mb-2" {...props}>{children}</ol>,
|
||||
li: ({ node, children, ...props }) => <li className="mb-1" {...props}>{children}</li>,
|
||||
h1: ({ node, children, ...props }) => <h1 className="text-lg font-bold mb-2" {...props}>{children}</h1>,
|
||||
h2: ({ node, children, ...props }) => <h2 className="text-base font-bold mb-2" {...props}>{children}</h2>,
|
||||
h3: ({ node, children, ...props }) => <h3 className="text-sm font-bold mb-1" {...props}>{children}</h3>,
|
||||
blockquote: ({ node, children, ...props }) => <blockquote className="border-l-4 border-muted-foreground/30 pl-3 italic text-muted-foreground my-2" {...props}>{children}</blockquote>,
|
||||
pre: ({ node, className, ...props }) => <pre className={`overflow-auto w-full my-2 bg-black/10 dark:bg-black/30 p-2 rounded ${className || ''}`} {...props} />,
|
||||
code: ({ node, className, children, ...props }) => {
|
||||
return <code className={`${className || ''} bg-black/5 dark:bg-white/10 rounded px-1`} {...props}>{children}</code>
|
||||
}
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
a: ({ href, children }) => <a href={href} className="text-blue-500 hover:underline" target="_blank" rel="noopener noreferrer">{children}</a>,
|
||||
pre: ({ className, ...props }) => <pre className={`overflow-auto w-full my-2 bg-black/10 dark:bg-black/30 p-2 rounded ${className || ''}`} {...props} />,
|
||||
code: ({ className, children, ...props }) => <code className={`${className || ''} bg-black/5 dark:bg-white/10 rounded px-1`} {...props}>{children}</code>,
|
||||
table: ({ children }) => <div className="overflow-x-auto my-4"><table className="min-w-full divide-y divide-gray-300 dark:divide-gray-700">{children}</table></div>,
|
||||
th: ({ children }) => <th className="px-3 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-left">{children}</th>,
|
||||
td: ({ children }) => <td className="px-3 py-2 border-t border-gray-200 dark:border-gray-700">{children}</td>,
|
||||
}}
|
||||
>
|
||||
{preprocessMarkdown(log.response)}
|
||||
@ -353,7 +447,7 @@ export function HistoryView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{idx < selectedSession.logs.length - 1 && <div className="my-8 h-px bg-border" />}
|
||||
{idx < displayLogs.length - 1 && <div className="my-8 h-px bg-border" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -362,7 +456,7 @@ export function HistoryView() {
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<MessageSquare className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>请选择一个会话查看详情</p>
|
||||
<p>请选择左侧公司或会话查看详情</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -130,7 +130,7 @@ function NavHeaderInner() {
|
||||
</div>
|
||||
|
||||
{/* Portal Target for Dynamic Content */}
|
||||
<div id="header-portal-target" className="flex-1 flex items-center justify-end px-4 gap-4 min-w-0" />
|
||||
<div id="header-portal-target" suppressHydrationWarning className="flex-1 flex items-center justify-end px-4 gap-4 min-w-0" />
|
||||
|
||||
<nav className="flex items-center gap-4 sm:gap-6 border-l pl-6">
|
||||
<Link className="text-sm font-medium hover:underline underline-offset-4" href="/">
|
||||
|
||||
119
frontend/src/components/side-panel-bloomberg.tsx
Normal file
119
frontend/src/components/side-panel-bloomberg.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
// Removed ScrollArea
|
||||
import { X, Loader2, AlertCircle } from "lucide-react"
|
||||
import { useFinancialData } from "@/hooks/use-financial-data"
|
||||
import { BloombergView } from "@/components/bloomberg-view"
|
||||
import type { SearchResult } from "@/lib/types"
|
||||
|
||||
interface SidePanelBloombergProps {
|
||||
company: SearchResult
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SidePanelBloomberg({ company, isOpen, onClose }: SidePanelBloombergProps) {
|
||||
// Only fetch when open to save resources? Or pre-fetch?
|
||||
// Let's fetch when isOpen becomes true or company changes.
|
||||
// useFinancialData handles fetching internally but depends on us triggering it or checking status.
|
||||
// Actually useFinancialData runs checkStatus on mount.
|
||||
|
||||
const {
|
||||
status,
|
||||
loading, // loading status check
|
||||
fetching, // fetching new data
|
||||
error,
|
||||
fetchData,
|
||||
checkStatus
|
||||
} = useFinancialData(company, "Bloomberg")
|
||||
|
||||
// Effect to trigger status check when opened if not already loaded?
|
||||
// useFinancialData does it on mount (when company changes).
|
||||
// so we just rely on that.
|
||||
|
||||
const hasData = status?.has_data && status?.company_id
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 right-0 z-50 w-[800px] bg-background border-l shadow-2xl transition-transform duration-300 ease-in-out transform",
|
||||
isOpen ? "translate-x-0" : "translate-x-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-lg font-semibold">Bloomberg Financial Data</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{company.company_name} ({company.symbol})
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden relative flex flex-col">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Checking data status...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-4 p-8 text-center">
|
||||
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||
<p className="text-destructive font-medium">{error}</p>
|
||||
<Button variant="outline" onClick={() => checkStatus()}>Retry</Button>
|
||||
</div>
|
||||
) : !hasData ? (
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-4 p-8 text-center">
|
||||
<p className="text-muted-foreground">No Bloomberg data found for this company.</p>
|
||||
<Button
|
||||
onClick={() => fetchData(true)}
|
||||
disabled={fetching}
|
||||
>
|
||||
{fetching ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Fetching...
|
||||
</>
|
||||
) : (
|
||||
"Fetch from Bloomberg"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
{status?.company_id && (
|
||||
<BloombergView
|
||||
companyId={status.company_id}
|
||||
userMarket={company.market}
|
||||
lastUpdate={status.last_update?.date}
|
||||
selectedCurrency="Auto"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user