const stockPreviewCache = new Map(); const STOCK_PREVIEW_DELAY = 380; const STOCK_PREVIEW_CACHE_MS = 5 * 60 * 1000; const LIVE_REFRESH_DEFAULT_MS = 10 * 1000; let stockPreviewOpenTimer = null; let stockPreviewCloseTimer = null; let stockPreviewAbortController = null; let stockPreviewAnchor = null; function bindStockRows(container) { animateRows(container); decorateStockPreviewTargets(container); container.querySelectorAll("[data-code]").forEach((rowElement) => { rowElement.addEventListener("click", (event) => { const interactive = event.target.closest("button, a, input, select, textarea, summary"); if (interactive && interactive !== rowElement) return; openStock(rowElement.dataset.code, findStockFallback(rowElement.dataset.code)); }); }); } function decorateStockPreviewTargets(container) { container.querySelectorAll(".stock-code").forEach((trigger) => { const code = stockCodeFromTrigger(trigger); if (!code) return; trigger.classList.add("stock-preview-trigger"); trigger.tabIndex = 0; trigger.setAttribute("role", "button"); trigger.setAttribute("aria-label", `预览 ${code} 行情`); trigger.title = "悬停预览行情,点击查看完整详情"; }); } function stockCodeFromTrigger(trigger) { const candidate = trigger?.dataset?.stockPreviewCode || trigger?.closest?.("[data-code]")?.dataset?.code || trigger?.textContent?.trim(); const matched = String(candidate || "").match(/\b(\d{6})\b/); return matched ? matched[1] : ""; } function marketPreviewTargetFromTrigger(trigger) { if (trigger?.classList?.contains("market-preview-trigger")) { const type = String(trigger.dataset.marketPreviewType || "").trim().toLowerCase(); const id = String(trigger.dataset.marketPreviewId || "").trim().toUpperCase(); if (type === "theme" && id) { const item = (state.themeLibrary?.items || []).find((row) => String(row.code) === id) || {}; return { type, id, code: id, name: item.name || trigger.textContent?.trim() || "--", type_label: "题材", change: item.change, value: item.close, }; } } const code = stockCodeFromTrigger(trigger); return code ? { type: "stock", id: code, code } : null; } function previewTriggerFromEvent(event) { return event.target.closest?.(".stock-preview-trigger, .market-preview-trigger"); } function showMarketPreview(target, trigger) { if (!target) return; if (target.type === "stock") showStockPreview(target.id, trigger); else showEntityPreview(target, trigger); } function findStockFallback(code) { const dashboardRows = [ ...(state.dashboard?.limits || []), ...(state.dashboard?.broken || []), ...(state.dashboard?.down_limits || []), ...(state.dashboard?.yesterday_limits || []), ]; const screenerRows = Object.values(state.screenerResultStore) .flatMap((entry) => entry?.result?.candidates || []); const dragonRows = (state.dragonTiger?.traders || []).flatMap((trader) => trader.operations || []); const auctionRows = state.auctionData?.rows || []; const themeRows = state.themeDetail?.members || []; const popularityRows = state.popularityData?.combined || []; const row = [...dashboardRows, ...screenerRows, ...dragonRows, ...auctionRows, ...themeRows, ...popularityRows, ...(state.watchlist || [])] .find((item) => String(item.code) === String(code)); if (!row) return { code, name: "--", sector: "其他" }; return { ...row, code, change: row.change ?? row.current_change ?? row.pct_chg ?? 0, sector: row.sector || row.industry || "其他", }; } function supportsStockPreviewHover() { return window.matchMedia("(hover: hover) and (pointer: fine)").matches && window.innerWidth > 720; } function handleStockPreviewPointerOver(event) { if (!supportsStockPreviewHover()) return; const trigger = previewTriggerFromEvent(event); if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return; const target = marketPreviewTargetFromTrigger(trigger); if (!target) return; cancelStockPreviewClose(); clearTimeout(stockPreviewOpenTimer); stockPreviewOpenTimer = setTimeout(() => showMarketPreview(target, trigger), STOCK_PREVIEW_DELAY); } function handleStockPreviewPointerOut(event) { if (!supportsStockPreviewHover()) return; const trigger = previewTriggerFromEvent(event); if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return; clearTimeout(stockPreviewOpenTimer); if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return; scheduleStockPreviewClose(); } function handleStockPreviewFocus(event) { if (!supportsStockPreviewHover()) return; const trigger = event.target.closest?.(".stock-preview-trigger"); if (!trigger) return; const code = stockCodeFromTrigger(trigger); if (!code) return; clearTimeout(stockPreviewOpenTimer); stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), 120); } function handleStockPreviewFocusOut(event) { const trigger = event.target.closest?.(".stock-preview-trigger"); if (!trigger) return; if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return; clearTimeout(stockPreviewOpenTimer); scheduleStockPreviewClose(); } function handleMobileStockPreviewClick(event) { if (window.innerWidth > 720) return; const trigger = event.target.closest?.(".stock-preview-trigger"); if (!trigger) return; const code = stockCodeFromTrigger(trigger); if (!code) return; event.preventDefault(); event.stopPropagation(); showStockPreview(code, trigger); } function handleStockPreviewKeydown(event) { if (event.key === "Escape" && !elements.stockPreview.hidden) { closeStockPreview(); stockPreviewAnchor?.focus?.(); return; } if (event.key !== "Enter") return; const trigger = event.target.closest?.(".stock-preview-trigger"); if (!trigger) return; const code = stockCodeFromTrigger(trigger); if (!code) return; event.preventDefault(); if (window.innerWidth <= 720) showStockPreview(code, trigger); else openStock(code, findStockFallback(code)); } function cancelStockPreviewClose() { clearTimeout(stockPreviewCloseTimer); } function scheduleStockPreviewClose() { clearTimeout(stockPreviewCloseTimer); stockPreviewCloseTimer = setTimeout(closeStockPreview, 160); } async function showStockPreview(code, trigger) { clearTimeout(stockPreviewOpenTimer); cancelStockPreviewClose(); if (!/^\d{6}$/.test(String(code))) return; stockPreviewAnchor = trigger; state.stockPreviewCode = String(code); state.stockPreviewType = "stock"; state.stockPreviewItem = null; state.stockPreviewFallback = findStockFallback(code); state.stockPreviewPayload = null; state.stockPreviewChart = "daily"; renderStockPreviewLoading(); elements.stockPreview.hidden = false; const mobile = window.innerWidth <= 720; elements.stockPreviewBackdrop.hidden = !mobile; document.body.classList.toggle("stock-preview-open", mobile); requestAnimationFrame(repositionStockPreview); const cacheKey = `${code}:latest`; const cached = stockPreviewCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { renderStockPreview(cached.payload); return; } if (cached) stockPreviewCache.delete(cacheKey); stockPreviewAbortController?.abort(); stockPreviewAbortController = new AbortController(); try { const payload = await apiRequest( `/api/stock/${encodeURIComponent(code)}/preview`, "GET", null, { signal: stockPreviewAbortController.signal }, ); if (state.stockPreviewCode !== String(code) || elements.stockPreview.hidden) return; const cacheMs = payload.meta?.realtime ? LIVE_REFRESH_DEFAULT_MS : STOCK_PREVIEW_CACHE_MS; stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + cacheMs }); while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value); renderStockPreview(payload); } catch (error) { if (error.name === "AbortError" || state.stockPreviewCode !== String(code)) return; renderStockPreviewError(error.message || "行情预览加载失败"); } } async function showEntityPreview(item, trigger) { const type = String(item?.type || "").trim().toLowerCase(); const id = String(item?.id || item?.code || "").trim().toUpperCase(); if (type !== "theme" || !id) return; clearTimeout(stockPreviewOpenTimer); cancelStockPreviewClose(); stockPreviewAnchor = trigger; state.stockPreviewCode = id; state.stockPreviewType = type; state.stockPreviewItem = { ...item, id, code: item.code || id, type, type_label: item.type_label || "题材" }; state.stockPreviewFallback = { code: item.code || id, name: item.name || "--", sector: item.type_label || "题材", price: item.value, change: item.change, }; state.stockPreviewPayload = null; state.stockPreviewChart = "daily"; renderStockPreviewLoading(); elements.stockPreview.hidden = false; const mobile = window.innerWidth <= 720; elements.stockPreviewBackdrop.hidden = !mobile; document.body.classList.toggle("stock-preview-open", mobile); requestAnimationFrame(repositionStockPreview); const cacheKey = `${type}:${id}:latest`; const cached = stockPreviewCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { renderStockPreview(cached.payload); return; } if (cached) stockPreviewCache.delete(cacheKey); stockPreviewAbortController?.abort(); stockPreviewAbortController = new AbortController(); try { const params = new URLSearchParams({ type, id, trade_date: todayString() }); const detail = await apiRequest( `/api/search/detail?${params}`, "GET", null, { signal: stockPreviewAbortController.signal }, ); if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return; const entity = detail.entity || {}; const payload = { stock: { code: entity.code || id, name: entity.name || item.name || "--", industry: entity.type_label || item.type_label || "题材", price: entity.value, change: entity.change, }, prices: detail.series || [], intraday: [], meta: { trade_date: detail.meta?.trade_date || "", realtime: Boolean(detail.meta?.realtime), intraday_status: "idle", intraday_notice: "", }, }; stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + STOCK_PREVIEW_CACHE_MS }); while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value); renderStockPreview(payload); } catch (error) { if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return; renderStockPreviewError(error.message || "题材行情预览加载失败"); } } function renderStockPreviewLoading() { const fallback = state.stockPreviewFallback || {}; selectStockPreviewChart("daily"); setText("stockPreviewCode", state.stockPreviewCode || "--"); setText("stockPreviewName", fallback.name || "正在加载"); setText("stockPreviewSector", fallback.sector || "--"); setText("stockPreviewPrice", "--"); setText("stockPreviewChange", "--"); document.querySelector("#stockPreviewChange").className = ""; setText("stockPreviewDate", "最新行情"); setText("stockPreviewSource", "正在读取行情"); setText("stockPreviewSummary", "等待行情数据"); document.querySelector("#stockPreviewLoading").hidden = false; clearStockPreviewChart(""); } function renderStockPreview(payload) { state.stockPreviewPayload = payload; const fallback = state.stockPreviewFallback || {}; const stock = payload.stock || {}; const price = stock.price; const change = stock.change; setText("stockPreviewCode", stock.code || state.stockPreviewCode); setText("stockPreviewName", stock.name && stock.name !== "--" ? stock.name : fallback.name || "--"); setText("stockPreviewSector", stock.industry && stock.industry !== "其他" ? stock.industry : fallback.sector || "其他"); setText("stockPreviewPrice", meaningfulNumber(price) ? formatNumber(price, 2) : "--"); setText("stockPreviewChange", meaningfulNumber(change) ? `${signed(change)}%` : "--"); document.querySelector("#stockPreviewChange").className = changeClass(change); document.querySelector("#stockPreviewLoading").hidden = true; selectStockPreviewChart("daily"); requestAnimationFrame(repositionStockPreview); } function renderStockPreviewError(message) { document.querySelector("#stockPreviewLoading").hidden = true; setText("stockPreviewSource", "行情加载失败"); setText("stockPreviewSummary", message); clearStockPreviewChart("加载失败"); } function selectStockPreviewChart(chart) { state.stockPreviewChart = chart === "daily" ? "daily" : "intraday"; document.querySelectorAll("[data-preview-chart]").forEach((button) => { const active = button.dataset.previewChart === state.stockPreviewChart; button.classList.toggle("active", active); button.setAttribute("aria-selected", String(active)); }); const payload = state.stockPreviewPayload; if (!payload) return; if (state.stockPreviewChart === "intraday") { if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "idle") { payload.meta.intraday_status = "loading"; setText("stockPreviewDate", "正在加载分时"); setText("stockPreviewSource", "正在读取最新分时"); setText("stockPreviewSummary", "等待分时行情数据"); clearStockPreviewChart(""); loadEntityPreviewIntraday(); return; } if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "loading") return; setText("stockPreviewDate", payload.meta?.intraday_trade_date || payload.meta?.trade_date || "最新行情"); setText( "stockPreviewSource", (payload.intraday || []).length ? "最新分时 · 1分钟" : "分时暂不可用", ); if ((payload.intraday || []).length) { drawIntradayPreviewChart( payload.intraday, payload.prices || [], payload.meta?.intraday_previous_close, ); } else { clearStockPreviewChart("分时数据不可用"); setText("stockPreviewSummary", payload.meta?.intraday_notice || "该交易日暂无分时数据。"); } } else if ((payload.prices || []).length) { setText("stockPreviewDate", payload.meta?.trade_date || "最新行情"); setText("stockPreviewSource", `日 K 行情 · ${payload.prices.length} 个交易日`); drawDailyPreviewChart(payload.prices); } else { setText("stockPreviewDate", payload.meta?.trade_date || "最新行情"); setText("stockPreviewSource", "日 K 行情暂不可用"); clearStockPreviewChart("暂无日K数据"); setText("stockPreviewSummary", "该股票暂无可用的日K数据。"); } } async function loadEntityPreviewIntraday() { const type = state.stockPreviewType; const id = state.stockPreviewCode; const payload = state.stockPreviewPayload; if (type === "stock" || !id || !payload) return; stockPreviewAbortController?.abort(); stockPreviewAbortController = new AbortController(); try { const params = new URLSearchParams({ type, id }); const intraday = await apiRequest( `/api/chart/intraday?${params}`, "GET", null, { signal: stockPreviewAbortController.signal }, ); if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return; payload.intraday = intraday.points || []; payload.meta.intraday_status = payload.intraday.length ? "available" : "empty"; payload.meta.intraday_trade_date = intraday.meta?.trade_date || ""; payload.meta.intraday_previous_close = intraday.meta?.previous_close || 0; payload.meta.intraday_notice = payload.intraday.length ? "" : "该题材暂无可用分时数据。"; if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday"); } catch (error) { if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return; payload.meta.intraday_status = "unavailable"; payload.meta.intraday_notice = error.message || "题材分时行情暂不可用。"; if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday"); } } function closeStockPreview() { clearTimeout(stockPreviewOpenTimer); clearTimeout(stockPreviewCloseTimer); stockPreviewAbortController?.abort(); stockPreviewAbortController = null; elements.stockPreview.hidden = true; elements.stockPreviewBackdrop.hidden = true; document.body.classList.remove("stock-preview-open"); state.stockPreviewPayload = null; state.stockPreviewCode = ""; state.stockPreviewType = "stock"; state.stockPreviewItem = null; } function openStockDetailFromPreview() { const code = state.stockPreviewCode; const fallback = state.stockPreviewFallback; const type = state.stockPreviewType; const item = state.stockPreviewItem; if (!code) return; closeStockPreview(); if (type === "stock") openStock(code, fallback); else if (item) openEntityDetail(item); } function repositionStockPreview() { if (elements.stockPreview.hidden || window.innerWidth <= 720 || !stockPreviewAnchor?.isConnected) return; const anchor = stockPreviewAnchor.getBoundingClientRect(); const preview = elements.stockPreview.getBoundingClientRect(); const gap = 12; let left = anchor.right + gap; if (left + preview.width > window.innerWidth - 8) left = anchor.left - preview.width - gap; left = clamp(left, 8, Math.max(8, window.innerWidth - preview.width - 8)); const top = clamp(anchor.top - 48, 64, Math.max(64, window.innerHeight - preview.height - 8)); elements.stockPreview.style.left = `${Math.round(left)}px`; elements.stockPreview.style.top = `${Math.round(top)}px`; }