Files
xiaobaifupan/app/frontend/pages/market/preview.js
T

447 lines
18 KiB
JavaScript

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 > 767;
}
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 > 767) 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 <= 767) 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 <= 767;
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 <= 767;
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 <= 767 || !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`;
}