Compare commits

..

2 Commits

Author SHA1 Message Date
xucheng
30e954d89f 优化pdf下载 2026-01-23 11:39:32 +08:00
xucheng
66f4914c5a feat: 优化股票图表 TradingView 链接生成逻辑并使搜索组件中的市场字段可编辑。 2026-01-23 10:55:54 +08:00
6 changed files with 85 additions and 30 deletions

View File

@ -236,7 +236,10 @@ class ExportPDFRequest(BaseModel):
async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(get_db)): async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(get_db)):
"""Export selected chat sessions to PDF""" """Export selected chat sessions to PDF"""
from sqlalchemy import select, desc, asc from sqlalchemy import select, desc, asc
from datetime import datetime from datetime import datetime, timezone, timedelta
# Timezone definition (Shanghai)
shanghai_tz = timezone(timedelta(hours=8))
if not request.session_ids: if not request.session_ids:
raise HTTPException(status_code=400, detail="No sessions selected") raise HTTPException(status_code=400, detail="No sessions selected")
@ -260,7 +263,13 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
messages_html = "" messages_html = ""
for log in logs: for log in logs:
timestamp = log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else "" # Convert timestamp to Shanghai time
if log.timestamp:
ts_shanghai = log.timestamp.astimezone(shanghai_tz) if log.timestamp.tzinfo else log.timestamp.replace(tzinfo=timezone.utc).astimezone(shanghai_tz)
timestamp = ts_shanghai.strftime('%Y-%m-%d %H:%M:%S')
else:
timestamp = ""
response_html = markdown.markdown(log.response or "", extensions=['tables', 'fenced_code']) response_html = markdown.markdown(log.response or "", extensions=['tables', 'fenced_code'])
# Add context info (Stock Code / Session) to the header since they are mixed # Add context info (Stock Code / Session) to the header since they are mixed
@ -275,7 +284,6 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
</div> </div>
<div class="message-sub-meta"> <div class="message-sub-meta">
<span class="session-id">Session: {session_info}</span> <span class="session-id">Session: {session_info}</span>
<span class="model">Model: {log.model}</span>
</div> </div>
<div class="message-content"> <div class="message-content">
{response_html} {response_html}
@ -284,6 +292,8 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
''' '''
# Complete HTML # Complete HTML
now_shanghai = datetime.now(shanghai_tz)
# Updated font-family stack to include common Linux/Windows CJK fonts # Updated font-family stack to include common Linux/Windows CJK fonts
complete_html = f''' complete_html = f'''
<!DOCTYPE html> <!DOCTYPE html>
@ -383,7 +393,7 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
</head> </head>
<body> <body>
<h1>AI 研究讨论导出</h1> <h1>AI 研究讨论导出</h1>
<p style="text-align: center; color: #666; margin-bottom: 30px;">导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p> <p style="text-align: center; color: #666; margin-bottom: 30px;">导出时间: {now_shanghai.strftime('%Y-%m-%d %H:%M:%S')}</p>
{messages_html} {messages_html}
</body> </body>
</html> </html>
@ -395,7 +405,13 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
pdf_path = tmp_file.name pdf_path = tmp_file.name
HTML(string=complete_html).write_pdf(pdf_path) HTML(string=complete_html).write_pdf(pdf_path)
filename = f"AI研究讨论汇总_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" # Determine filename prefix based on the most common stock code in logs
# or just the first one found
company_name = "AI研究讨论"
if logs and logs[0].stock_code:
company_name = logs[0].stock_code
filename = f"{company_name}_{now_shanghai.strftime('%Y%m%d')}_讨论记录.pdf"
filename_encoded = quote(filename) filename_encoded = quote(filename)
return FileResponse( return FileResponse(

View File

@ -343,6 +343,17 @@ async def download_report_pdf(report_id: int, db: AsyncSession = Depends(get_db)
""" """
# Complete PDF HTML # Complete PDF HTML
# Timezone conversion for display
from datetime import datetime, timezone, timedelta
shanghai_tz = timezone(timedelta(hours=8))
# Use report creation time for the report date in PDF
report_time = report.created_at.astimezone(shanghai_tz) if report.created_at.tzinfo else report.created_at.replace(tzinfo=timezone.utc).astimezone(shanghai_tz)
# Current time for filename (download date)
now_shanghai = datetime.now(shanghai_tz)
download_date_str = now_shanghai.strftime('%Y%m%d')
complete_html = f""" complete_html = f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -515,8 +526,7 @@ async def download_report_pdf(report_id: int, db: AsyncSession = Depends(get_db)
<div class="cover"> <div class="cover">
<h1>{report.company_name}</h1> <h1>{report.company_name}</h1>
<div class="meta">{report.market} {report.symbol}</div> <div class="meta">{report.market} {report.symbol}</div>
<div class="meta">分析日期: {report.created_at.strftime('%Y年%m月%d')}</div> <div class="meta">分析日期: {report_time.strftime('%Y年%m月%d')}</div>
{f'<div class="meta">AI模型: {report.ai_model}</div>' if report.ai_model else ''}
</div> </div>
<div class="section"> <div class="section">
@ -538,7 +548,8 @@ async def download_report_pdf(report_id: int, db: AsyncSession = Depends(get_db)
HTML(string=complete_html).write_pdf(pdf_path) HTML(string=complete_html).write_pdf(pdf_path)
# Return the PDF file # Return the PDF file
filename = f"{report.company_name}_{report.symbol}_分析报告.pdf" # Filename format: Company_Symbol_Date_Report.pdf
filename = f"{report.company_name}_{report.symbol}_{download_date_str}_分析报告.pdf"
# Use RFC 5987 encoding for non-ASCII filenames # Use RFC 5987 encoding for non-ASCII filenames
filename_encoded = quote(filename) filename_encoded = quote(filename)

View File

@ -75,7 +75,14 @@ export default function AnalysisPage({ params }: { params: Promise<{ id: string
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `${report.company_name}_${report.symbol}_分析报告.pdf`
// Format date as YYYYMMDD to match backend
const date = new Date()
const dateStr = date.getFullYear() +
String(date.getMonth() + 1).padStart(2, '0') +
String(date.getDate()).padStart(2, '0')
a.download = `${report.company_name}_${report.symbol}_${dateStr}_分析报告.pdf`
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)

View File

@ -250,11 +250,30 @@ export function HistoryView() {
body: JSON.stringify({ session_ids: Array.from(selectedForExport) }) body: JSON.stringify({ session_ids: Array.from(selectedForExport) })
}) })
if (!response.ok) throw new Error("导出失败") if (!response.ok) throw new Error("导出失败")
// Try to get filename from content-disposition
let filename = `讨论历史_${new Date().toISOString().slice(0, 10)}.pdf`
const disposition = response.headers.get('content-disposition')
if (disposition && disposition.indexOf('attachment') !== -1) {
const filenameRegex = /filename\*=UTF-8''([\w%\-\.]+)(?:;|$)|filename="([^"]+)"(?:;|$)|filename=([^;]+)(?:;|$)/i;
const matches = filenameRegex.exec(disposition);
if (matches) {
// Try decoding the UTF-8 encoded filename
try {
if (matches[1]) filename = decodeURIComponent(matches[1]);
else if (matches[2]) filename = matches[2];
else if (matches[3]) filename = matches[3];
} catch (e) {
console.error("Error decoding filename:", e);
}
}
}
const blob = await response.blob() const blob = await response.blob()
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)
const a = document.createElement("a") const a = document.createElement("a")
a.href = url a.href = url
a.download = `讨论历史_${new Date().toISOString().slice(0, 10)}.pdf` a.download = filename
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)

View File

@ -32,9 +32,10 @@ function SearchResultItem({ result, onSelect }: { result: SearchResult, onSelect
const defaultSource = "Bloomberg" const defaultSource = "Bloomberg"
const [source, setSource] = useState(defaultSource) const [source, setSource] = useState(defaultSource)
const [symbol, setSymbol] = useState(result.symbol) // Editable symbol state const [symbol, setSymbol] = useState(result.symbol) // Editable symbol state
const [market, setMarket] = useState(result.market) // Editable market state
// 根据市场获取可用数据源列表 // 根据市场获取可用数据源列表
const availableSources = result.market === "CN" ? DATA_SOURCES.CN : DATA_SOURCES.GLOBAL const availableSources = market === "CN" ? DATA_SOURCES.CN : DATA_SOURCES.GLOBAL
return ( return (
<Card className="hover:bg-accent/50 transition-colors h-full flex flex-col"> <Card className="hover:bg-accent/50 transition-colors h-full flex flex-col">
@ -54,9 +55,13 @@ function SearchResultItem({ result, onSelect }: { result: SearchResult, onSelect
/> />
</div> </div>
</div> </div>
<Badge variant="secondary" className="shrink-0 text-xs font-mono h-6"> <div className="shrink-0 w-12">
{result.market} <Input
</Badge> value={market}
onChange={(e) => setMarket(e.target.value)}
className="h-6 text-xs font-mono px-1 text-center bg-secondary/50 border-transparent hover:border-input focus:border-input transition-colors"
/>
</div>
</div> </div>
</div> </div>
@ -78,7 +83,7 @@ function SearchResultItem({ result, onSelect }: { result: SearchResult, onSelect
</Select> </Select>
</div> </div>
<Button <Button
onClick={() => onSelect({ ...result, symbol }, source)} onClick={() => onSelect({ ...result, symbol, market }, source)}
size="sm" size="sm"
className="h-8 px-3 shrink-0" className="h-8 px-3 shrink-0"
> >

View File

@ -12,28 +12,25 @@ interface StockChartProps {
export function StockChart({ symbol, market }: StockChartProps) { export function StockChart({ symbol, market }: StockChartProps) {
const getTradingViewUrl = () => { const getTradingViewUrl = () => {
let exchange = "NASDAQ" // 对于特定亚洲市场,需要显式指定交易所前缀
let tvSymbol = symbol
if (market === "CH" || market === "CN") { if (market === "CH" || market === "CN") {
if (symbol.startsWith("6")) exchange = "SSE" let exchange = "SSE" // Default Shanghai
else if (symbol.startsWith("0") || symbol.startsWith("3")) exchange = "SZSE" if (symbol.startsWith("0") || symbol.startsWith("3")) exchange = "SZSE"
else if (symbol.startsWith("4") || symbol.startsWith("8")) exchange = "BSE" else if (symbol.startsWith("4") || symbol.startsWith("8")) exchange = "BSE"
return `https://cn.tradingview.com/chart/?symbol=${exchange}:${symbol}`
} else if (market === "HK") { } else if (market === "HK") {
exchange = "HKEX" // 港股去重前面的0
// Ensure no leading zeros for int conversion check const tvSymbol = parseInt(symbol).toString()
tvSymbol = parseInt(symbol).toString() return `https://cn.tradingview.com/chart/?symbol=HKEX:${tvSymbol}`
} else if (market === "JP") { } else if (market === "JP") {
exchange = "TSE" return `https://cn.tradingview.com/chart/?symbol=TSE:${symbol}`
} else if (market === "VN") { } else if (market === "VN") {
exchange = "HOSE" return `https://cn.tradingview.com/chart/?symbol=HOSE:${symbol}`
} else {
// US
exchange = "NASDAQ" // Default fallback
} }
const fullSymbol = `${exchange}:${tvSymbol}` // 对于其他市场 (US, BZ, UK 等),直接传递 Symbol 让 TradingView 自动匹配
return `https://cn.tradingview.com/chart/?symbol=${fullSymbol}` // 例如 "PETR4" -> Bovespa, "C" -> NYSE, "AAPL" -> NASDAQ
return `https://cn.tradingview.com/chart/?symbol=${symbol}`
} }
useEffect(() => { useEffect(() => {