Compare commits
2 Commits
f471813b95
...
b6222b9b4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6222b9b4b | ||
|
|
5b501e3984 |
@ -321,3 +321,18 @@ async def get_recent_companies(
|
|||||||
db=db,
|
db=db,
|
||||||
limit=20
|
limit=20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search_local", response_model=List[dict])
|
||||||
|
async def search_local_companies(
|
||||||
|
q: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
搜索本地数据库中的公司
|
||||||
|
"""
|
||||||
|
return await data_fetcher_service.search_companies(
|
||||||
|
query=q,
|
||||||
|
db=db,
|
||||||
|
limit=10
|
||||||
|
)
|
||||||
|
|||||||
@ -534,4 +534,50 @@ async def get_recent_companies(
|
|||||||
if len(companies) >= limit:
|
if len(companies) >= limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
return companies
|
return companies
|
||||||
|
|
||||||
|
async def search_companies(
|
||||||
|
query: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
limit: int = 10
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
搜索本地公司库
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: 搜索关键词 (代码或名称)
|
||||||
|
db: 数据库会话
|
||||||
|
limit: 返回数量
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
公司列表
|
||||||
|
"""
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 构建模糊查询
|
||||||
|
search_term = f"%{query}%"
|
||||||
|
|
||||||
|
# 查找 symbol 或 company_name 匹配的公司
|
||||||
|
stmt = (
|
||||||
|
select(Company)
|
||||||
|
.where(
|
||||||
|
(Company.symbol.ilike(search_term)) |
|
||||||
|
(Company.company_name.ilike(search_term))
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
companies = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"market": c.market,
|
||||||
|
"symbol": c.symbol,
|
||||||
|
"company_name": c.company_name,
|
||||||
|
"id": c.id
|
||||||
|
}
|
||||||
|
for c in companies
|
||||||
|
]
|
||||||
|
|||||||
@ -12,12 +12,16 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { searchLocalCompanies } from "@/lib/api"
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
type RecentCompany = {
|
type RecentCompany = {
|
||||||
market: string
|
market: string
|
||||||
symbol: string
|
symbol: string
|
||||||
company_name: string
|
company_name: string
|
||||||
last_update: string
|
last_update?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavHeaderInner() {
|
function NavHeaderInner() {
|
||||||
@ -25,7 +29,13 @@ function NavHeaderInner() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
const [dataSource, setDataSource] = useState("Bloomberg")
|
const [dataSource, setDataSource] = useState("Bloomberg")
|
||||||
const [companies, setCompanies] = useState<RecentCompany[]>([])
|
|
||||||
|
// Quick Access Search State
|
||||||
|
const [inputValue, setInputValue] = useState("")
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [results, setResults] = useState<RecentCompany[]>([])
|
||||||
|
const debouncedValue = useDebounce(inputValue, 300)
|
||||||
|
|
||||||
// Sync state with URL params
|
// Sync state with URL params
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -35,10 +45,50 @@ function NavHeaderInner() {
|
|||||||
}
|
}
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
|
// Load initial recent companies
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRecent = async () => {
|
||||||
|
// Only fetch if input is empty to show history
|
||||||
|
if (!inputValue) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/data/recent?data_source=${dataSource}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setResults(Array.isArray(data) ? data : [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch recent companies:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchRecent()
|
||||||
|
}, [dataSource, inputValue])
|
||||||
|
|
||||||
|
// Handle Search
|
||||||
|
useEffect(() => {
|
||||||
|
const doSearch = async () => {
|
||||||
|
if (!debouncedValue) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await searchLocalCompanies(debouncedValue)
|
||||||
|
setResults(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search error:", error)
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debouncedValue) {
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
}, [debouncedValue])
|
||||||
|
|
||||||
|
|
||||||
const handleDataSourceChange = (newSource: string) => {
|
const handleDataSourceChange = (newSource: string) => {
|
||||||
setDataSource(newSource)
|
setDataSource(newSource)
|
||||||
|
|
||||||
// If viewing a company, update URL to trigger reload with new source
|
|
||||||
const currentSymbol = searchParams.get('symbol')
|
const currentSymbol = searchParams.get('symbol')
|
||||||
if (currentSymbol) {
|
if (currentSymbol) {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
@ -47,43 +97,73 @@ function NavHeaderInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recent companies
|
const inferMarket = (symbol: string): string[] => {
|
||||||
useEffect(() => {
|
// 6 digits -> China A-Share
|
||||||
const fetchRecent = async () => {
|
if (/^\d{6}$/.test(symbol)) return ["CH"]
|
||||||
try {
|
|
||||||
// Use relative path since we are in same domain mainly
|
|
||||||
const res = await fetch(`/api/data/recent?data_source=${dataSource}`)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
setCompanies(Array.isArray(data) ? data : [])
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch recent companies:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchRecent()
|
|
||||||
}, [dataSource])
|
|
||||||
|
|
||||||
const handleCompanyChange = (value: string) => {
|
// 4 digits -> Japan or HK
|
||||||
if (!value) return
|
if (/^\d{4}$/.test(symbol)) return ["JP", "HK"]
|
||||||
const [symbol, market] = value.split(':')
|
|
||||||
const company = companies.find(c => c.symbol === symbol && c.market === market)
|
|
||||||
|
|
||||||
// Push URL params to trigger page state update
|
// 5 digits or 1-3 digits -> HK
|
||||||
// We encode company name as well to display it nicely before full details load
|
if (/^\d{5}$/.test(symbol) || /^\d{1,3}$/.test(symbol)) return ["HK"]
|
||||||
|
|
||||||
|
// Letters -> US or Vietnam
|
||||||
|
if (/^[A-Za-z]+$/.test(symbol)) return ["US", "VN"]
|
||||||
|
|
||||||
|
// Default fallbacks if pattern matches nothing specific but has digits
|
||||||
|
if (/^\d+$/.test(symbol)) return ["CH"]
|
||||||
|
|
||||||
|
return ["CH"] // Ultimate default
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (symbol: string, market: string, name?: string) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('symbol', symbol)
|
params.set('symbol', symbol)
|
||||||
params.set('market', market)
|
params.set('market', market)
|
||||||
params.set('source', dataSource)
|
params.set('source', dataSource)
|
||||||
if (company) params.set('name', company.company_name)
|
if (name) params.set('name', name)
|
||||||
params.set('t', Date.now().toString()) // Force update
|
params.set('t', Date.now().toString())
|
||||||
|
|
||||||
router.push(`/?${params.toString()}`)
|
router.push(`/?${params.toString()}`)
|
||||||
|
setIsOpen(false)
|
||||||
|
setInputValue("") // Clear input after selection
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseManualInput = (input: string): { symbol: string, market: string } | null => {
|
||||||
|
const match = input.trim().match(/^([^\s]+)\s+([a-zA-Z]+)$/)
|
||||||
|
if (match) {
|
||||||
|
return { symbol: match[1], market: match[2].toUpperCase() }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && inputValue) {
|
||||||
|
// Check if there is an exact match
|
||||||
|
const exactMatch = results.find(r => r.symbol === inputValue)
|
||||||
|
if (exactMatch) {
|
||||||
|
handleSelect(exactMatch.symbol, exactMatch.market, exactMatch.company_name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for manual input first
|
||||||
|
const manual = parseManualInput(inputValue)
|
||||||
|
if (manual) {
|
||||||
|
handleSelect(manual.symbol, manual.market, manual.symbol)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no exact match and no manual input, use the first inferred market
|
||||||
|
const markets = inferMarket(inputValue)
|
||||||
|
if (markets.length > 0) {
|
||||||
|
handleSelect(inputValue, markets[0], inputValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-[60] relative">
|
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-[60] relative">
|
||||||
<div className="flex h-14 items-center gap-4 px-6 lg:h-[60px] w-full px-8"> {/* Adjusted constraint */}
|
<div className="flex h-14 items-center gap-4 px-6 lg:h-[60px] w-full px-8">
|
||||||
<Link className="flex items-center gap-2 font-semibold min-w-[140px]" href="/">
|
<Link className="flex items-center gap-2 font-semibold min-w-[140px]" href="/">
|
||||||
<MonitorPlay className="h-6 w-6" />
|
<MonitorPlay className="h-6 w-6" />
|
||||||
<span className="">股票分析 AI</span>
|
<span className="">股票分析 AI</span>
|
||||||
@ -104,25 +184,75 @@ function NavHeaderInner() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select onValueChange={handleCompanyChange}>
|
{/* Custom Combobox for Quick Access */}
|
||||||
<SelectTrigger className="w-[120px] h-8 text-xs font-mono">
|
<div className="relative w-[180px]">
|
||||||
<SelectValue placeholder="代码..." />
|
<Input
|
||||||
</SelectTrigger>
|
className="h-8 text-xs font-mono"
|
||||||
<SelectContent className="z-[70]">
|
placeholder="代码/名称..."
|
||||||
{companies.map((c) => (
|
value={inputValue}
|
||||||
<SelectItem key={`${c.symbol}:${c.market}`} value={`${c.symbol}:${c.market}`}>
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value)
|
||||||
|
setIsOpen(true)
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onBlur={() => setTimeout(() => setIsOpen(false), 200)} // Delay close to allow clicks
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<Loader2 className="absolute right-2 top-2 h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full mt-1 left-0 w-[240px] bg-popover text-popover-foreground rounded-md border shadow-md z-[80] overflow-hidden max-h-[300px] overflow-y-auto">
|
||||||
|
<div className="p-1">
|
||||||
|
{results.map((c) => (
|
||||||
|
<button
|
||||||
|
key={`${c.symbol}:${c.market}`}
|
||||||
|
className="w-full flex items-center justify-between p-2 hover:bg-accent hover:text-accent-foreground rounded-sm text-xs text-left transition-colors"
|
||||||
|
onClick={() => handleSelect(c.symbol, c.market, c.company_name)}
|
||||||
|
>
|
||||||
<span className="flex items-center gap-2 w-full">
|
<span className="flex items-center gap-2 w-full">
|
||||||
<span className="font-bold w-[50px] text-left">{c.symbol}</span>
|
<span className="font-bold w-[50px] text-left">{c.symbol}</span>
|
||||||
<span className="truncate flex-1 text-left">{c.company_name}</span>
|
<span className="truncate flex-1 text-left">{c.company_name}</span>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
<span className="text-[10px] text-muted-foreground">{c.market}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
{companies.length === 0 && (
|
|
||||||
<div className="p-2 text-xs text-muted-foreground text-center">无记录</div>
|
{results.length === 0 && inputValue && !isLoading && (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
const manual = parseManualInput(inputValue)
|
||||||
|
if (manual) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="w-full p-2 hover:bg-accent hover:text-accent-foreground rounded-sm text-xs text-left text-blue-500 flex items-center gap-2"
|
||||||
|
onClick={() => handleSelect(manual.symbol, manual.market, manual.symbol)}
|
||||||
|
>
|
||||||
|
<span>前往 <strong>{manual.symbol}</strong> ({manual.market}) - 手动指定</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return inferMarket(inputValue).map(m => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
className="w-full p-2 hover:bg-accent hover:text-accent-foreground rounded-sm text-xs text-left text-blue-500 flex items-center gap-2"
|
||||||
|
onClick={() => handleSelect(inputValue, m, inputValue)}
|
||||||
|
>
|
||||||
|
<span>前往 <strong>{inputValue}</strong> ({m})</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
{results.length === 0 && !inputValue && (
|
||||||
|
<div className="p-2 text-xs text-muted-foreground text-center">最近无访问记录</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[160px] ml-4">
|
<div className="w-[160px] ml-4">
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect } from "react"
|
||||||
|
import { ExternalLink } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
interface StockChartProps {
|
interface StockChartProps {
|
||||||
symbol: string
|
symbol: string
|
||||||
@ -9,26 +10,8 @@ interface StockChartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StockChart({ symbol, market }: StockChartProps) {
|
export function StockChart({ symbol, market }: StockChartProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const getTradingViewUrl = () => {
|
||||||
if (!containerRef.current) return
|
|
||||||
|
|
||||||
// Clear previous widget
|
|
||||||
containerRef.current.innerHTML = ""
|
|
||||||
|
|
||||||
// Create wrapper for widget
|
|
||||||
const widgetContainer = document.createElement("div")
|
|
||||||
widgetContainer.className = "tradingview-widget-container__widget h-full w-full"
|
|
||||||
containerRef.current.appendChild(widgetContainer)
|
|
||||||
|
|
||||||
const script = document.createElement("script")
|
|
||||||
script.src = "https://s3.tradingview.com/external-embedding/embed-widget-advanced-chart.js"
|
|
||||||
script.type = "text/javascript"
|
|
||||||
script.async = true
|
|
||||||
|
|
||||||
// Map Market/Symbol to TradingView format
|
|
||||||
console.log("[StockChart] Received market:", JSON.stringify(market), "symbol:", symbol)
|
|
||||||
let exchange = "NASDAQ"
|
let exchange = "NASDAQ"
|
||||||
let tvSymbol = symbol
|
let tvSymbol = symbol
|
||||||
|
|
||||||
@ -38,52 +21,38 @@ export function StockChart({ symbol, market }: StockChartProps) {
|
|||||||
else if (symbol.startsWith("4") || symbol.startsWith("8")) exchange = "BSE"
|
else if (symbol.startsWith("4") || symbol.startsWith("8")) exchange = "BSE"
|
||||||
} else if (market === "HK") {
|
} else if (market === "HK") {
|
||||||
exchange = "HKEX"
|
exchange = "HKEX"
|
||||||
// TradingView usually expects HK stocks without leading zeros if they are 4 digits, but let's check.
|
// Ensure no leading zeros for int conversion check
|
||||||
// Actually HKEX:700 works, HKEX:0700 might work too. Let's try to keep it safe.
|
|
||||||
tvSymbol = parseInt(symbol).toString()
|
tvSymbol = parseInt(symbol).toString()
|
||||||
} else if (market === "JP") {
|
} else if (market === "JP") {
|
||||||
exchange = "TSE"
|
exchange = "TSE"
|
||||||
} else if (market === "VN") {
|
} else if (market === "VN") {
|
||||||
exchange = "HOSE" // Primary VN exchange
|
exchange = "HOSE"
|
||||||
} else {
|
} else {
|
||||||
// US
|
// US
|
||||||
exchange = "NASDAQ" // Default, could be NYSE
|
exchange = "NASDAQ" // Default fallback
|
||||||
// Basic heuristic for US
|
|
||||||
// If 4 chars or more, likely Nasdaq, <=3 likely NYSE? Not 100% accurate but acceptable default.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullSymbol = `${exchange}:${tvSymbol}`
|
const fullSymbol = `${exchange}:${tvSymbol}`
|
||||||
|
return `https://cn.tradingview.com/chart/?symbol=${fullSymbol}`
|
||||||
|
}
|
||||||
|
|
||||||
script.innerHTML = JSON.stringify({
|
useEffect(() => {
|
||||||
"autosize": true,
|
const url = getTradingViewUrl()
|
||||||
"symbol": fullSymbol,
|
// Attempt to open in new tab automatically
|
||||||
"interval": "D",
|
const win = window.open(url, '_blank')
|
||||||
"range": "12m",
|
if (!win) {
|
||||||
"scale": "log",
|
console.warn("Popup blocked. Please allow popups for this site.")
|
||||||
"timezone": "Asia/Shanghai",
|
}
|
||||||
"theme": "light",
|
|
||||||
"style": "1",
|
|
||||||
"locale": "zh_CN",
|
|
||||||
"enable_publishing": false,
|
|
||||||
"allow_symbol_change": true,
|
|
||||||
"calendar": false,
|
|
||||||
"support_host": "https://www.tradingview.com"
|
|
||||||
})
|
|
||||||
|
|
||||||
containerRef.current.appendChild(script)
|
|
||||||
|
|
||||||
}, [symbol, market])
|
}, [symbol, market])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-100px)] w-full bg-background rounded-lg border overflow-hidden p-1">
|
<div className="w-full h-12 bg-muted/30 rounded-lg border border-dashed flex items-center justify-center">
|
||||||
<div className="tradingview-widget-container h-full w-full flex flex-col" ref={containerRef}>
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<div className="tradingview-widget-copyright text-xs text-center text-muted-foreground p-2 mt-auto border-t bg-muted/20">
|
<span>正在前往 TradingView ({symbol})...</span>
|
||||||
<a href={`https://cn.tradingview.com/chart/?symbol=${symbol}`} rel="noopener nofollow" target="_blank" className="hover:text-primary transition-colors flex items-center justify-center gap-1">
|
<a href={getTradingViewUrl()} target="_blank" rel="noopener noreferrer" className="underline hover:text-primary">
|
||||||
<span className="font-medium">在 TradingView 上查看完整图表</span>
|
若未跳转请点击
|
||||||
<span className="text-[10px] opacity-70">({symbol})</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
15
frontend/src/hooks/use-debounce.ts
Normal file
15
frontend/src/hooks/use-debounce.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay?: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedValue(value), delay || 500)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [value, delay])
|
||||||
|
|
||||||
|
return debouncedValue
|
||||||
|
}
|
||||||
@ -29,6 +29,12 @@ export async function searchStock(query: string, model?: string) {
|
|||||||
return res.json() as Promise<{ market: string; symbol: string; company_name: string }[]>;
|
return res.json() as Promise<{ market: string; symbol: string; company_name: string }[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchLocalCompanies(query: string) {
|
||||||
|
const res = await fetch(`${API_BASE}/data/search_local?q=${query}`);
|
||||||
|
if (!res.ok) throw new Error("Local search failed");
|
||||||
|
return res.json() as Promise<{ market: string; symbol: string; company_name: string }[]>;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 配置相关 ==========
|
// ========== 配置相关 ==========
|
||||||
export async function getConfig() {
|
export async function getConfig() {
|
||||||
const res = await fetch(`${API_BASE}/config`);
|
const res = await fetch(`${API_BASE}/config`);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user