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

1334 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = '<div class="global-search-loading"><span class="spinner" aria-hidden="true"></span><span>正在搜索</span></div>';
globalSearchTimer = setTimeout(() => runGlobalSearch(query), 160);
}
async function runGlobalSearch(query) {
const requestSequence = ++state.globalSearchRequestSequence;
const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
try {
const payload = await apiRequest(`/api/search?${params}`);
if (requestSequence !== state.globalSearchRequestSequence || elements.globalSearchInput.value.trim() !== query) return;
renderGlobalSearchResults(payload.groups || {});
} catch (error) {
if (requestSequence !== state.globalSearchRequestSequence) return;
state.globalSearchResults = [];
renderGlobalSearchEmpty(error.message || "搜索失败", "请稍后重试", "circle-alert");
}
}
function renderGlobalSearchResults(groups) {
const definitions = [
["stocks", "股票"],
["sectors", "板块"],
["themes", "题材"],
["indices", "指数"],
];
const iconNames = { stock: "chart-candlestick", sector: "layout-grid", theme: "lightbulb", index: "chart-line" };
const flattened = [];
const sections = [];
definitions.forEach(([key, label]) => {
const items = Array.isArray(groups[key]) ? groups[key] : [];
if (!items.length) return;
const rows = items.map((item) => {
const index = flattened.length;
flattened.push(item);
return `<button class="global-search-result" type="button" role="option" aria-selected="false" data-search-result-index="${index}">
<span class="global-search-result-icon"><i data-lucide="${iconNames[item.type] || "search"}"></i></span>
<span class="global-search-result-copy"><strong>${escapeHtml(item.name || "--")}</strong><span>${escapeHtml(item.subtitle || item.type_label || label)}</span></span>
<span class="global-search-result-code">${escapeHtml(item.code || "")}</span>
</button>`;
}).join("");
sections.push(`<section class="global-search-group" aria-label="${label}"><h3 class="global-search-group-title">${label}</h3>${rows}</section>`);
});
state.globalSearchResults = flattened;
state.globalSearchActiveIndex = flattened.length ? 0 : -1;
if (!flattened.length) {
renderGlobalSearchEmpty("没有找到相关结果", "可尝试输入完整名称或六位股票代码", "search-x");
return;
}
elements.globalSearchResults.innerHTML = sections.join("");
updateGlobalSearchSelection(false);
refreshIcons();
}
function renderGlobalSearchEmpty(title, hint, iconName) {
elements.globalSearchResults.innerHTML = `<div class="global-search-empty"><i data-lucide="${iconName}"></i><p>${escapeHtml(title)}</p><span>${escapeHtml(hint)}</span></div>`;
refreshIcons();
}
function handleGlobalSearchInputKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
closeGlobalSearch();
return;
}
if (!["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return;
if (!state.globalSearchResults.length) return;
event.preventDefault();
if (event.key === "Enter") {
openGlobalSearchResult(state.globalSearchActiveIndex);
return;
}
const direction = event.key === "ArrowDown" ? 1 : -1;
state.globalSearchActiveIndex = (state.globalSearchActiveIndex + direction + state.globalSearchResults.length) % state.globalSearchResults.length;
updateGlobalSearchSelection(true);
}
function updateGlobalSearchSelection(scrollIntoView) {
elements.globalSearchResults.querySelectorAll("[data-search-result-index]").forEach((item) => {
const selected = number(item.dataset.searchResultIndex) === state.globalSearchActiveIndex;
item.classList.toggle("is-active", selected);
item.setAttribute("aria-selected", String(selected));
if (selected && scrollIntoView) item.scrollIntoView({ block: "nearest" });
});
}
function openGlobalSearchResult(index) {
const item = state.globalSearchResults[index];
if (!item) return;
closeGlobalSearch();
if (item.type === "stock") {
openStock(item.id, { code: item.code, name: item.name, sector: item.industry || "其他" });
return;
}
openEntityDetail(item);
}
async function openEntityDetail(item) {
state.entityDetailItem = item;
state.entityDetailPayload = null;
state.entityDetailIntraday = null;
state.entityDetailChartMode = "daily";
const requestSequence = ++state.entityDetailRequestSequence;
syncDetailChartButtons("entity", "daily");
setText("entityDetailCode", item.code || item.id || "--");
setText("entityDetailName", item.name || "--");
setText("entityDetailValue", "--");
setText("entityDetailChange", "--");
setText("entityDetailType", item.type_label || "--");
setText("entityDetailDate", "正在加载行情");
document.querySelector("#entityDetailChange").className = "";
renderEmptyState("entityDetailMetrics", "正在加载交易数据");
openModalDialog(elements.entityDetailDialog);
clearEntityDetailChart("正在加载日 K 数据");
try {
const params = new URLSearchParams({ type: item.type, id: item.id, trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/search/detail?${params}`);
if (requestSequence !== state.entityDetailRequestSequence) return;
state.entityDetailPayload = payload;
const entity = payload.entity || {};
setText("entityDetailCode", entity.code || item.code || "--");
setText("entityDetailName", entity.name || item.name || "--");
setText("entityDetailValue", meaningfulNumber(entity.value) && number(entity.value) !== 0 ? formatNumber(entity.value, 2) : "--");
setText("entityDetailChange", `${signed(entity.change)}%`);
setText("entityDetailType", entity.type_label || item.type_label || "--");
document.querySelector("#entityDetailChange").className = changeClass(entity.change);
renderEntityDetailMetrics(payload.metrics || []);
if (state.entityDetailChartMode === "daily") {
setText("entityDetailDate", `${payload.meta?.realtime ? "实时" : "收盘"} · ${payload.meta?.trade_date || "--"}`);
requestAnimationFrame(() => drawEntityDetailChart(payload.series || []));
}
} catch (error) {
if (requestSequence !== state.entityDetailRequestSequence) return;
setText("entityDetailDate", "行情加载失败");
renderEmptyState("entityDetailMetrics", error.message || "交易数据加载失败");
if (state.entityDetailChartMode === "daily") clearEntityDetailChart(error.message || "行情加载失败");
showToast(error.message || "详情加载失败");
}
}
async function selectEntityDetailChart(mode) {
const selected = mode === "intraday" ? "intraday" : "daily";
state.entityDetailChartMode = selected;
syncDetailChartButtons("entity", selected);
if (selected === "daily") {
const payload = state.entityDetailPayload;
if (payload) {
setText("entityDetailDate", `${payload.meta?.realtime ? "实时" : "收盘"} · ${payload.meta?.trade_date || "--"}`);
requestAnimationFrame(() => drawEntityDetailChart(payload.series || []));
} else clearEntityDetailChart("正在加载日 K 数据");
return;
}
if (state.entityDetailIntraday) {
renderEntityIntraday(state.entityDetailIntraday);
return;
}
const item = state.entityDetailItem;
if (!item) return;
const requestSequence = state.entityDetailRequestSequence;
setText("entityDetailDate", "正在加载分时");
clearEntityDetailChart("正在加载分时数据");
try {
const params = new URLSearchParams({ type: item.type, id: item.id });
const payload = await apiRequest(`/api/chart/intraday?${params}`);
if (requestSequence !== state.entityDetailRequestSequence) return;
state.entityDetailIntraday = payload;
if (state.entityDetailChartMode === "intraday") renderEntityIntraday(payload);
} catch (error) {
if (requestSequence !== state.entityDetailRequestSequence || state.entityDetailChartMode !== "intraday") return;
setText("entityDetailDate", "分时暂不可用");
clearEntityDetailChart(error.message || "分时行情暂不可用");
}
}
function renderEntityIntraday(payload) {
const points = payload.points || [];
if (!points.length) {
setText("entityDetailDate", "分时暂不可用");
clearEntityDetailChart("分时行情暂不可用");
return;
}
setText("entityDetailDate", `分时 · ${payload.meta?.trade_date || "--"}`);
requestAnimationFrame(() => {
if (state.entityDetailChartMode !== "intraday") return;
drawIntradayCanvas(elements.entityDetailChart, points, [], payload.meta?.previous_close);
});
}
function syncDetailChartButtons(scope, mode) {
const selector = scope === "stock" ? "[data-stock-detail-chart]" : "[data-entity-detail-chart]";
const datasetKey = scope === "stock" ? "stockDetailChart" : "entityDetailChart";
document.querySelectorAll(selector).forEach((button) => {
const active = button.dataset[datasetKey] === mode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
}
function renderEntityDetailMetrics(metrics) {
const container = document.querySelector("#entityDetailMetrics");
if (!metrics.length) {
renderEmptyState(container, "暂无交易数据");
return;
}
container.innerHTML = metrics.map((metric) => {
const value = typeof metric.value === "number" ? formatNumber(metric.value, Number.isInteger(metric.value) ? 0 : 2) : String(metric.value ?? "--");
const tone = metric.tone === "change" ? changeClass(metric.value) : "";
return `<div><dt>${escapeHtml(metric.label)}</dt><dd class="${tone}">${escapeHtml(value)}${escapeHtml(metric.unit || "")}</dd></div>`;
}).join("");
}
function drawEntityDetailChart(series, canvas = elements.entityDetailChart) {
const candles = (series || []).filter((item) => number(item.close) > 0).map((item) => {
const close = number(item.close);
const open = number(item.open) || close;
const high = Math.max(number(item.high) || close, open, close);
const low = Math.min(number(item.low) || close, open, close);
return { ...item, open, high, low, close };
});
if (!candles.length) {
clearEntityDetailChart("暂无日 K 数据", canvas);
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 maximum = Math.max(...candles.map((item) => item.high));
const minimum = Math.min(...candles.map((item) => item.low));
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
const maxVolume = Math.max(...candles.map((item) => number(item.volume)), 1);
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
const step = plotWidth / candles.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 lineY = top + (priceBottom - top) * line / 4;
context.beginPath();
context.moveTo(left, lineY);
context.lineTo(width - right, lineY);
context.stroke();
context.fillText((maximum - range * line / 4).toFixed(2), left - 6, lineY + 4);
}
candles.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.72;
context.fillRect(x - candleWidth / 2, height - bottom - volumeBarHeight, candleWidth, volumeBarHeight);
context.globalAlpha = 1;
});
context.textAlign = "center";
context.fillStyle = palette.axis;
[0, Math.floor((candles.length - 1) / 2), candles.length - 1].forEach((index) => {
const x = left + step * index + step / 2;
context.fillText(String(candles[index].trade_date || "").slice(5), x, height - 5);
});
}
function clearEntityDetailChart(message, canvas = elements.entityDetailChart) {
const rect = canvas.getBoundingClientRect();
const width = Math.max(320, Math.round(rect.width || 680));
const height = Math.max(220, Math.round(rect.height || 300));
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
const palette = currentChartPalette();
context.fillStyle = palette.background;
context.fillRect(0, 0, width, height);
context.fillStyle = palette.axis;
context.font = "13px Microsoft YaHei";
context.textAlign = "center";
context.fillText(message, width / 2, height / 2);
}
function meaningfulNumber(value) {
return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value));
}
async function openStock(code, fallback = null) {
closeStockPreview();
const pools = [state.dashboard?.limits || [], state.dashboard?.broken || [], state.dashboard?.down_limits || []];
const row = pools.flat().find((item) => String(item.code) === String(code)) || fallback || { code, name: "--", sector: "其他" };
state.activeStock = row;
state.stockDetail = null;
state.stockDetailIntraday = null;
state.stockDetailChartMode = "daily";
const requestSequence = ++state.stockDetailRequestSequence;
syncDetailChartButtons("stock", "daily");
setText("detailCode", row.code);
setText("detailName", row.name);
setText("detailPrice", formatNumber(row.price, 2));
setText("detailChange", `${signed(row.change)}%`);
const changeElement = document.querySelector("#detailChange");
changeElement.className = changeClass(row.change);
setText("detailStreak", row.status === "涨停" ? streakLabel(row.streak) : row.status || "--");
setText("detailReason", row.reason || "--");
setText("detailSector", row.sector || "其他");
setText("detailFirst", row.first_time || "--");
setText("detailLast", row.last_time || "--");
setText("detailOpen", `${number(row.open_times)} 次`);
setText("detailTurnover", `${formatNumber(row.turnover_rate, 2)}%`);
setText("detailAmount", `${formatNumber(row.amount_billion, 2)} 亿`);
setText("detailSeal", `${formatNumber(row.seal_amount_million, 0)} 万`);
setText("chartSource", "正在加载行情");
setText("flowNet", "--");
setText("flowLarge", "--");
setText("flowMedium", "--");
setText("flowSmall", "--");
document.querySelector("#reasonInput").value = row.reason || "";
document.querySelector("#stockNoteContent").value = "";
document.querySelector("#stockNotePlan").value = "";
renderEmptyState("stockNotes", "正在加载笔记");
updateWatchButton();
openModalDialog(elements.stockDialog);
clearPriceChart("正在加载日 K 数据");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/stock/${encodeURIComponent(code)}?${query}`);
if (requestSequence !== state.stockDetailRequestSequence) return;
state.stockDetail = payload;
const stock = payload.stock || {};
state.activeStock = { ...row, name: stock.name || row.name, sector: stock.industry || row.sector };
setText("detailName", stock.name || row.name);
setText("detailPrice", formatNumber(stock.price || row.price, 2));
setText("detailChange", `${signed(stock.change ?? row.change)}%`);
renderMoneyflow(payload.moneyflow || {});
renderStockNotes(payload.notes || []);
updateWatchButton();
if (state.stockDetailChartMode === "daily") {
setText("chartSource", `日 K 行情 · ${payload.prices.length} 个交易日`);
requestAnimationFrame(() => drawPriceChart(payload.prices || []));
}
} catch (error) {
if (requestSequence !== state.stockDetailRequestSequence) return;
setText("chartSource", "行情加载失败");
if (state.stockDetailChartMode === "daily") clearPriceChart(error.message || "行情加载失败");
showToast(error.message || "个股详情加载失败");
}
}
async function selectStockDetailChart(mode) {
const selected = mode === "intraday" ? "intraday" : "daily";
state.stockDetailChartMode = selected;
syncDetailChartButtons("stock", selected);
if (selected === "daily") {
const prices = state.stockDetail?.prices || [];
setText("chartSource", prices.length ? `日 K 行情 · ${prices.length} 个交易日` : "正在加载行情");
if (prices.length) requestAnimationFrame(() => drawPriceChart(prices));
else clearPriceChart("正在加载日 K 数据");
return;
}
if (state.stockDetailIntraday) {
renderStockDetailIntraday(state.stockDetailIntraday);
return;
}
const code = String(state.activeStock?.code || "");
if (!/^\d{6}$/.test(code)) return;
const requestSequence = state.stockDetailRequestSequence;
setText("chartSource", "正在加载分时");
clearPriceChart("正在加载分时数据");
try {
const params = new URLSearchParams({ type: "stock", id: code });
const payload = await apiRequest(`/api/chart/intraday?${params}`);
if (requestSequence !== state.stockDetailRequestSequence) return;
state.stockDetailIntraday = payload;
if (state.stockDetailChartMode === "intraday") renderStockDetailIntraday(payload);
} catch (error) {
if (requestSequence !== state.stockDetailRequestSequence || state.stockDetailChartMode !== "intraday") return;
setText("chartSource", "分时暂不可用");
clearPriceChart(error.message || "分时行情暂不可用");
}
}
function renderStockDetailIntraday(payload) {
const points = payload.points || [];
if (!points.length) {
setText("chartSource", "分时暂不可用");
clearPriceChart("分时行情暂不可用");
return;
}
setText("chartSource", `分时 · ${payload.meta?.trade_date || "--"}`);
requestAnimationFrame(() => {
if (state.stockDetailChartMode !== "intraday") return;
drawIntradayCanvas(elements.priceChart, points, [], payload.meta?.previous_close);
});
}
function openActiveStockInHeaven() {
const code = state.activeStock?.code;
if (!/^\d{6}$/.test(String(code || ""))) return;
elements.stockDialog.close();
state.heavenPanel = "trend";
state.heavenManualData = null;
const input = document.querySelector("#heavenStockInput");
input.value = code;
openView("heavenView");
selectHeavenPanel("trend", true);
}
/* PRESERVATION-SOURCE-END app.js:7969-8426 */