refactor: establish standalone application boundary
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
function bindMarketEvents() {
|
||||
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
|
||||
document.querySelector("#closeGlobalSearch").addEventListener("click", closeGlobalSearch);
|
||||
document.querySelector("#closeEntityDetail").addEventListener("click", () => elements.entityDetailDialog.close());
|
||||
document.querySelectorAll("[data-entity-detail-chart]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectEntityDetailChart(button.dataset.entityDetailChart));
|
||||
});
|
||||
elements.globalSearchDialog.addEventListener("click", (event) => {
|
||||
if (event.target === elements.globalSearchDialog) closeGlobalSearch();
|
||||
});
|
||||
elements.globalSearchInput.addEventListener("input", scheduleGlobalSearch);
|
||||
elements.globalSearchInput.addEventListener("keydown", handleGlobalSearchInputKeydown);
|
||||
elements.globalSearchResults.addEventListener("click", (event) => {
|
||||
const result = event.target.closest("[data-search-result-index]");
|
||||
if (result) openGlobalSearchResult(number(result.dataset.searchResultIndex));
|
||||
});
|
||||
document.querySelector("#closeStockDialog").addEventListener("click", () => elements.stockDialog.close());
|
||||
document.querySelectorAll("[data-stock-detail-chart]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectStockDetailChart(button.dataset.stockDetailChart));
|
||||
});
|
||||
document.querySelector("#closeStockPreview").addEventListener("click", closeStockPreview);
|
||||
elements.stockPreviewBackdrop.addEventListener("click", closeStockPreview);
|
||||
document.querySelector("#openStockDetailFromPreview").addEventListener("click", openStockDetailFromPreview);
|
||||
document.querySelectorAll("[data-preview-chart]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectStockPreviewChart(button.dataset.previewChart));
|
||||
});
|
||||
elements.stockPreview.addEventListener("pointerenter", cancelStockPreviewClose);
|
||||
elements.stockPreview.addEventListener("pointerleave", scheduleStockPreviewClose);
|
||||
document.addEventListener("pointerover", handleStockPreviewPointerOver);
|
||||
document.addEventListener("pointerout", handleStockPreviewPointerOut);
|
||||
document.addEventListener("focusin", handleStockPreviewFocus);
|
||||
document.addEventListener("focusout", handleStockPreviewFocusOut);
|
||||
document.addEventListener("click", handleMobileStockPreviewClick, true);
|
||||
document.addEventListener("keydown", handleStockPreviewKeydown);
|
||||
document.addEventListener("scroll", repositionStockPreview, true);
|
||||
window.addEventListener("keydown", handleGlobalSearchShortcut);
|
||||
window.addEventListener("resize", () => {
|
||||
if (!elements.stockPreview.hidden) closeStockPreview();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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)}%`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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);
|
||||
}
|
||||
+1325
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,446 @@
|
||||
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`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
let globalSearchTimer = null;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user