/* PRESERVATION-SOURCE-BEGIN app.js:1916-1957 */ function renderMarketBreadth(overview) { const up = number(overview.up_count); const down = number(overview.down_count); const flat = Math.max(0, number(overview.flat_count)); const total = Math.max(1, up + down + flat); const upRate = up / total * 100; const flatRate = flat / total * 100; const downRate = down / total * 100; const panel = document.querySelector(".market-breadth-panel"); panel.classList.remove("breadth-enter"); void panel.offsetWidth; panel.classList.add("breadth-enter"); setText("breadthDataTime", dashboardDataTimestamp(state.dashboard?.meta || {})); animateMetric("breadthRatio", upRate, (value) => `${formatNumber(value, 1)}%`); animateMetric("breadthUpCount", up, (value) => formatNumber(Math.round(value))); animateMetric("breadthDownCount", down, (value) => formatNumber(Math.round(value))); setText("breadthUpLegend", `${formatNumber(up)}(${formatNumber(upRate, 1)}%)`); setText("breadthFlatLegend", `${formatNumber(flat)}(${formatNumber(flatRate, 1)}%)`); setText("breadthDownLegend", `${formatNumber(down)}(${formatNumber(downRate, 1)}%)`); document.querySelector("#breadthFlatLegendItem").hidden = flat === 0; const limitUp = number(overview.limit_up_count); const limitDown = number(overview.limit_down_count); const breadthLabel = upRate < 20 ? "宽度极差" : upRate < 40 ? "宽度偏弱" : upRate < 55 ? "宽度均衡" : "宽度偏强"; setText("breadthWarning", `△ ${breadthLabel},涨跌停 ${limitUp}:${limitDown}`); const bars = [ ["breadthUpBar", upRate], ["breadthFlatBar", flatRate], ["breadthDownBar", downRate], ]; bars.forEach(([id, width]) => { const bar = document.getElementById(id); const targetWidth = `${Math.max(width, width > 0 ? 0.8 : 0)}%`; bar.style.transition = "none"; bar.style.width = "0%"; requestAnimationFrame(() => requestAnimationFrame(() => { bar.style.transition = "width 760ms var(--ease-out)"; bar.style.width = targetWidth; })); bar.title = `${formatNumber(width, 1)}%`; }); } /* PRESERVATION-SOURCE-END app.js:1916-1957 */ /* PRESERVATION-SOURCE-BEGIN app.js:6893-7719 */ function currentChartPalette() { const style = getComputedStyle(document.documentElement); const color = (token, fallback) => style.getPropertyValue(token).trim() || fallback; return { background: color("--chart-background", "#fbfcfd"), grid: color("--chart-grid", "#e2e8ec"), axis: color("--chart-axis", "#6c7983"), zero: color("--chart-zero", "#aeb7c1"), line: color("--chart-line", "#1d65c1"), average: color("--chart-average", "#b7791f"), up: color("--chart-up", "#c93f45"), down: color("--chart-down", "#087a55"), upVolume: color("--chart-up-volume", "rgba(201, 63, 69, .58)"), downVolume: color("--chart-down-volume", "rgba(8, 122, 85, .58)"), area: color("--chart-area", "rgba(37, 99, 235, .07)"), alertArea: color("--chart-alert-area", "rgba(224, 69, 54, .05)"), movingAverage: color("--chart-moving-average", "#d1d5db"), repair: color("--chart-repair", "#f59e0b"), ma10: color("--chart-ma-10", "#a76500"), ma20: color("--chart-ma-20", "#626c78"), }; } function drawCandlestick(context, x, item, priceY, candleWidth, palette = currentChartPalette()) { const rising = number(item.close) >= number(item.open); const color = rising ? palette.up : palette.down; const highY = priceY(item.high); const lowY = priceY(item.low); const openY = priceY(item.open); const closeY = priceY(item.close); const bodyTop = Math.min(openY, closeY); const bodyBottom = Math.max(openY, closeY); const bodyHeight = Math.max(1, bodyBottom - bodyTop); context.strokeStyle = color; context.fillStyle = color; context.lineWidth = 1; context.beginPath(); context.moveTo(x, highY); context.lineTo(x, bodyTop); context.moveTo(x, bodyBottom); context.lineTo(x, lowY); context.stroke(); const bodyLeft = x - candleWidth / 2; if (rising) { context.fillStyle = palette.background; context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight); context.strokeStyle = color; context.strokeRect(bodyLeft, bodyTop, candleWidth, bodyHeight); } else { context.fillStyle = color; context.fillRect(bodyLeft, bodyTop, candleWidth, bodyHeight); } return color; } function drawPriceChart(prices) { const canvas = elements.priceChart; if (!prices?.length) { clearPriceChart("暂无日 K 数据"); return; } const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const width = Math.max(320, rect.width); const height = Math.max(220, rect.height); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); const left = 48; const right = 12; const top = 14; const bottom = 22; const volumeHeight = 54; const gap = 12; const priceBottom = height - bottom - volumeHeight - gap; const plotWidth = width - left - right; const highs = prices.map((item) => number(item.high)); const lows = prices.map((item) => number(item.low)); const maximum = Math.max(...highs); const minimum = Math.min(...lows); const range = Math.max(maximum - minimum, maximum * 0.01, 0.01); const volumes = prices.map((item) => number(item.volume)); const maxVolume = Math.max(...volumes, 1); const priceY = (value) => top + (maximum - value) / range * (priceBottom - top); const step = plotWidth / prices.length; const candleWidth = clamp(step * 0.62, 2, 8); context.strokeStyle = palette.grid; context.fillStyle = palette.axis; context.font = "11px Microsoft YaHei"; context.textAlign = "right"; for (let line = 0; line <= 4; line += 1) { const y = top + (priceBottom - top) * line / 4; context.beginPath(); context.moveTo(left, y); context.lineTo(width - right, y); context.stroke(); context.fillText((maximum - range * line / 4).toFixed(2), left - 5, y + 4); } prices.forEach((item, index) => { const x = left + step * index + step / 2; const color = drawCandlestick(context, x, item, priceY, candleWidth, palette); const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight; context.fillStyle = color; context.globalAlpha = 0.75; context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight); context.globalAlpha = 1; }); context.textAlign = "center"; context.fillStyle = palette.axis; const labelIndexes = [0, Math.floor((prices.length - 1) / 2), prices.length - 1]; labelIndexes.forEach((index) => { const x = left + step * index + step / 2; context.fillText(String(prices[index].trade_date).slice(5), x, height - 5); }); } function clearPriceChart(message) { const canvas = elements.priceChart; const context = canvas.getContext("2d"); const rect = canvas.getBoundingClientRect(); canvas.width = Math.max(320, Math.round(rect.width)); canvas.height = Math.max(220, Math.round(rect.height)); const palette = currentChartPalette(); context.fillStyle = palette.background; context.fillRect(0, 0, canvas.width, canvas.height); context.fillStyle = palette.axis; context.font = "13px Microsoft YaHei"; context.textAlign = "center"; context.fillText(message, canvas.width / 2, canvas.height / 2); } function prepareStockPreviewCanvas() { const canvas = elements.stockPreviewChart; const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const width = Math.max(300, rect.width || 488); const height = Math.max(210, rect.height || 232); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif'; return { canvas, context, width, height, palette }; } function drawPreviewGrid(context, width, top, bottom, left, right, maximum, range) { const palette = currentChartPalette(); context.strokeStyle = palette.grid; context.fillStyle = palette.axis; context.textAlign = "right"; context.lineWidth = 1; for (let line = 0; line <= 3; line += 1) { const y = top + (bottom - top) * line / 3; context.beginPath(); context.moveTo(left, y); context.lineTo(width - right, y); context.stroke(); context.fillText((maximum - range * line / 3).toFixed(2), left - 5, y + 4); } } function intradayMinuteOffset(value) { const [hour, minute] = String(value || "").split(":").map((part) => number(part)); const clockMinute = hour * 60 + minute; const morningStart = 9 * 60 + 30; const morningEnd = 11 * 60 + 30; const afternoonStart = 13 * 60; const afternoonEnd = 15 * 60; if (clockMinute <= morningEnd) return clamp(clockMinute - morningStart, 0, 120); if (clockMinute < afternoonStart) return 120; return 120 + clamp(clockMinute - afternoonStart, 0, afternoonEnd - afternoonStart); } function drawIntradayCanvas(canvas, points, dailyPrices = [], referenceClose = 0) { const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const width = Math.max(300, rect.width || 488); const height = Math.max(210, rect.height || 232); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); context.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei UI", sans-serif'; const left = 45; const right = 10; const top = 12; const volumeHeight = 38; const bottom = 18; const gap = 9; const priceBottom = height - bottom - volumeHeight - gap; const closes = points.map((point) => number(point.close)); const previousClose = number(referenceClose || dailyPrices.at(-2)?.close || points[0]?.open || closes[0]); const maximum = Math.max(...points.map((point) => number(point.high || point.close)), previousClose); const minimum = Math.min(...points.map((point) => number(point.low || point.close)), previousClose); const deviation = Math.max( Math.abs(maximum - previousClose), Math.abs(previousClose - minimum), previousClose * 0.003, 0.01, ) * 1.08; const chartMaximum = previousClose + deviation; const chartMinimum = previousClose - deviation; const range = Math.max(chartMaximum - chartMinimum, 0.01); const plotWidth = width - left - right; const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top); const pointX = (index) => left + plotWidth * intradayMinuteOffset(points[index]?.time) / 240; drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range); context.save(); context.setLineDash([4, 4]); context.strokeStyle = palette.zero; context.beginPath(); context.moveTo(left, priceY(previousClose)); context.lineTo(width - right, priceY(previousClose)); context.stroke(); context.restore(); context.fillStyle = palette.axis; context.textAlign = "right"; context.fillText("0.00%", width - right, priceY(previousClose) - 4); context.strokeStyle = palette.line; context.lineWidth = 1.7; context.beginPath(); points.forEach((point, index) => { const x = pointX(index); const y = priceY(point.close); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.stroke(); const averages = points.map((point) => number(point.average)).filter((value) => value > 0); if (averages.length) { context.strokeStyle = palette.average; context.lineWidth = 1.25; context.beginPath(); let averageStarted = false; points.forEach((point, index) => { const average = number(point.average); if (average <= 0) return; const x = pointX(index); const y = priceY(average); if (!averageStarted) { context.moveTo(x, y); averageStarted = true; } else context.lineTo(x, y); }); context.stroke(); } const maxVolume = Math.max(...points.map((point) => number(point.volume)), 1); const barWidth = clamp(plotWidth / Math.max(points.length, 1) * 0.72, 1, 3); points.forEach((point, index) => { const x = pointX(index); const barHeight = number(point.volume) / maxVolume * volumeHeight; context.fillStyle = number(point.close) >= number(point.open) ? palette.upVolume : palette.downVolume; context.fillRect(x - barWidth / 2, height - bottom - barHeight, barWidth, barHeight); }); context.fillStyle = palette.axis; context.textAlign = "center"; [ { offset: 0, label: "09:30" }, { offset: 120, label: "11:30 / 13:00" }, { offset: 240, label: "15:00" }, ].forEach((marker) => { context.fillText(marker.label, left + plotWidth * marker.offset / 240, height - 4); }); return { latest: closes.at(-1), maximum, minimum, }; } function drawIntradayPreviewChart(points, dailyPrices, referenceClose = 0) { const summary = drawIntradayCanvas(elements.stockPreviewChart, points, dailyPrices, referenceClose); setText( "stockPreviewSummary", `分时 ${points.length} 点,最新 ${formatNumber(summary.latest, 2)},最高 ${formatNumber(summary.maximum, 2)},最低 ${formatNumber(summary.minimum, 2)}。`, ); } function drawDailyPreviewChart(prices) { const { context, width, height, palette } = prepareStockPreviewCanvas(); const visible = prices.slice(-45); const visibleStart = prices.length - visible.length; const left = 45; const right = 10; const top = 24; const volumeHeight = 34; const bottom = 18; const gap = 8; const priceBottom = height - bottom - volumeHeight - gap; const maximum = Math.max(...visible.map((item) => number(item.high))); const minimum = Math.min(...visible.map((item) => number(item.low))); const padding = Math.max((maximum - minimum) * 0.05, maximum * 0.002, 0.01); const chartMaximum = maximum + padding; const chartMinimum = minimum - padding; const range = Math.max(chartMaximum - chartMinimum, 0.01); const plotWidth = width - left - right; const step = plotWidth / Math.max(visible.length, 1); const candleWidth = clamp(step * 0.58, 2, 7); const priceY = (value) => top + (chartMaximum - value) / range * (priceBottom - top); drawPreviewGrid(context, width, top, priceBottom, left, right, chartMaximum, range); const maxVolume = Math.max(...visible.map((item) => number(item.volume)), 1); visible.forEach((item, index) => { const x = left + step * index + step / 2; const color = drawCandlestick(context, x, item, priceY, candleWidth, palette); const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight; context.fillStyle = color; context.globalAlpha = 0.62; context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight); context.globalAlpha = 1; }); const movingAverages = [ { days: 5, color: palette.line }, { days: 10, color: palette.ma10 }, { days: 20, color: palette.ma20 }, ]; movingAverages.forEach(({ days, color }) => { context.strokeStyle = color; context.lineWidth = 1.25; context.beginPath(); let started = false; visible.forEach((_item, index) => { const absoluteIndex = visibleStart + index; if (absoluteIndex < days - 1) return; const values = prices.slice(absoluteIndex - days + 1, absoluteIndex + 1); const average = values.reduce((sum, item) => sum + number(item.close), 0) / days; const x = left + step * index + step / 2; const y = priceY(average); if (!started) { context.moveTo(x, y); started = true; } else context.lineTo(x, y); }); context.stroke(); }); context.textAlign = "left"; movingAverages.forEach(({ days, color }, index) => { context.fillStyle = color; context.fillText(`MA${days}`, left + index * 42, 12); }); context.fillStyle = palette.axis; context.textAlign = "center"; [0, Math.floor((visible.length - 1) / 2), visible.length - 1].forEach((index) => { const x = left + step * index + step / 2; context.fillText(String(visible[index]?.trade_date || "").slice(5), x, height - 4); }); const firstClose = number(visible[0]?.close); const latestClose = number(visible.at(-1)?.close); const periodChange = firstClose ? (latestClose / firstClose - 1) * 100 : 0; setText( "stockPreviewSummary", `近 ${visible.length} 日涨跌 ${signed(periodChange)}%,区间最高 ${formatNumber(maximum, 2)},最低 ${formatNumber(minimum, 2)}。`, ); } function clearStockPreviewChart(message) { const { context, width, height } = prepareStockPreviewCanvas(); if (!message) return; context.fillStyle = "#74808d"; context.textAlign = "center"; context.fillText(message, width / 2, height / 2); } 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`; } /* PRESERVATION-SOURCE-END app.js:6893-7719 */ /* PRESERVATION-SOURCE-BEGIN app.js:7969-8426 */ function openGlobalSearch() { if (!state.user) return; toggleHeaderCommandMenu(false); openModalDialog(elements.globalSearchDialog); requestAnimationFrame(() => { elements.globalSearchInput.focus(); elements.globalSearchInput.select(); }); } function handleGlobalSearchShortcut(event) { if (!event.ctrlKey || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return; if (!state.user) return; if (event.defaultPrevented) { showToast("Ctrl+K 已被其他功能占用,请点击顶部搜索按钮"); return; } event.preventDefault(); openGlobalSearch(); } function closeGlobalSearch() { clearTimeout(globalSearchTimer); if (elements.globalSearchDialog.open) elements.globalSearchDialog.close(); } function scheduleGlobalSearch() { clearTimeout(globalSearchTimer); const query = elements.globalSearchInput.value.trim(); state.globalSearchActiveIndex = -1; if (!query) { state.globalSearchResults = []; renderGlobalSearchEmpty("输入名称或代码开始搜索", "使用方向键选择,回车打开详情", "corner-down-left"); return; } elements.globalSearchResults.innerHTML = '
${escapeHtml(title)}
${escapeHtml(hint)}