200 lines
8.9 KiB
JavaScript
200 lines
8.9 KiB
JavaScript
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);
|
|
}
|