447 lines
18 KiB
JavaScript
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 > 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`;
|
|
}
|