Files
xiaobaifupan/app/frontend/pages/dragon-tiger/page.js
T

417 lines
22 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.
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
bind: bindDragonTigerEvents,
enter: ["loadDragonTiger"],
});
function selectDragonViewMode(mode) {
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => {
const active = button.dataset.dragonViewMode === state.dragonViewMode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
if (state.dragonViewMode === "profiles") {
document.querySelector("#dragonDailyContent").hidden = true;
document.querySelector("#dragonEmptyState").hidden = true;
document.querySelector("#dragonProfilesContent").hidden = false;
if (state.hotMoneyProfiles) renderHotMoneyProfiles();
else loadHotMoneyProfiles();
} else {
document.querySelector("#dragonProfilesContent").hidden = true;
if (state.dragonTiger) renderDragonTiger();
else loadDragonTiger();
}
}
async function loadHotMoneyProfiles(force = false) {
if (!force && state.hotMoneyProfiles) {
renderHotMoneyProfiles();
return;
}
setStatus("正在加载游资档案");
try {
const query = new URLSearchParams();
if (force) query.set("force", "1");
const suffix = query.size ? `?${query}` : "";
state.hotMoneyProfiles = await apiRequest(`/api/dragon-tiger/profiles${suffix}`);
renderHotMoneyProfiles();
const count = number(state.hotMoneyProfiles.summary?.profile_count);
setStatus(`游资档案已加载 · 共 ${count} 位`);
} catch (error) {
showToast(error.message || "游资档案加载失败");
setStatus("游资档案加载失败");
}
}
function renderHotMoneyProfiles() {
const payload = state.hotMoneyProfiles;
if (!payload) return;
const profiles = payload.profiles || [];
const summary = payload.summary || {};
const query = state.hotMoneyProfileQuery;
const visible = profiles.filter((profile) => {
if (!query) return true;
return [profile.name, profile.description, ...(profile.organizations || [])]
.join(" ")
.toLocaleLowerCase("zh-CN")
.includes(query);
});
if (!visible.some((profile) => profile.id === state.selectedHotMoneyProfileId)) {
state.selectedHotMoneyProfileId = visible[0]?.id || "";
}
const selected = visible.find((profile) => profile.id === state.selectedHotMoneyProfileId) || null;
setText("dragonDateLabel", `收录 ${number(summary.profile_count)} 位`);
setText("hotMoneyProfileResultCount", query ? `${visible.length} / ${profiles.length} 位` : `${profiles.length} 位`);
document.querySelector("#hotMoneyProfileSummary").innerHTML = [
["收录游资", number(summary.profile_count)],
["已有简介", number(summary.described_count)],
["关联席位", number(summary.organization_count)],
].map(([label, value]) => `<span><small>${label}</small><strong>${value}</strong></span>`).join("");
const list = document.querySelector("#hotMoneyProfileList");
list.innerHTML = visible.length ? visible.map((profile, index) => `
<button class="hot-money-profile-row-v2 ${profile.id === state.selectedHotMoneyProfileId ? "selected" : ""}"
type="button" role="option" aria-selected="${profile.id === state.selectedHotMoneyProfileId}"
data-hot-money-profile="${escapeHtml(profile.id)}">
<span class="hot-money-profile-index-v2">${String(index + 1).padStart(2, "0")}</span>
<span class="hot-money-profile-monogram-v2">${escapeHtml(profile.name.slice(0, 2))}</span>
<span class="hot-money-profile-row-copy-v2">
<strong>${escapeHtml(profile.name)}</strong>
<small>${escapeHtml(profile.description || "暂未收录简介")}</small>
</span>
<span class="hot-money-profile-seat-count-v2">${number(profile.organization_count)} 席</span>
</button>`).join("") : `
<div class="hot-money-profile-list-empty-v2">
<i data-lucide="search-x" aria-hidden="true"></i>
<span>${profiles.length ? "没有符合条件的游资档案" : "游资名录暂不可用"}</span>
</div>`;
const detail = document.querySelector("#hotMoneyProfileDetail");
if (!selected) {
detail.innerHTML = `
<div class="hot-money-profile-empty-v2">
<i data-lucide="contact" aria-hidden="true"></i>
<strong>${profiles.length ? "选择一位游资查看档案" : "暂无可展示的游资档案"}</strong>
</div>`;
} else {
const organizations = selected.organizations || [];
detail.innerHTML = `
<header class="hot-money-profile-detail-head-v2">
<span class="hot-money-profile-avatar-v2">${escapeHtml(selected.name.slice(0, 2))}</span>
<div>
<small>游资档案</small>
<h3>${escapeHtml(selected.name)}</h3>
<span>${organizations.length ? `关联 ${organizations.length} 个公开席位` : "暂无关联席位"}</span>
</div>
</header>
<section class="hot-money-profile-section-v2">
<h4>人物简介</h4>
<p class="${selected.description ? "" : "is-empty"}">${escapeHtml(selected.description || "名录暂未收录该游资的公开简介。")}</p>
</section>
<section class="hot-money-profile-section-v2 hot-money-profile-org-section-v2">
<div class="hot-money-profile-section-title-v2">
<h4>关联营业部</h4>
<span>${organizations.length} 个</span>
</div>
<div class="hot-money-profile-organizations-v2">
${organizations.length ? organizations.map((organization) => `
<span><i data-lucide="building-2" aria-hidden="true"></i>${escapeHtml(organization)}</span>
`).join("") : '<p class="is-empty">名录暂未收录关联营业部。</p>'}
</div>
</section>
${payload.meta?.notice ? `<p class="hot-money-profile-notice-v2">${escapeHtml(payload.meta.notice)}</p>` : ""}`;
}
refreshIcons();
}
async function loadDragonTiger(force = false) {
const requestedDate = elements.tradeDate.value;
if (
!force
&& ["success", "empty", "partial", "unavailable"].includes(state.dragonTiger?.meta?.status)
&& (state.dragonTiger?.meta?.requested_date || state.dragonTiger?.meta?.trade_date) === requestedDate
) {
renderDragonTiger();
return;
}
setStatus("正在加载龙虎榜");
try {
const query = new URLSearchParams({ trade_date: requestedDate });
if (force) query.set("force", "1");
const payload = await apiRequest(`/api/dragon-tiger?${query}`);
state.dragonTiger = payload;
renderDragonTiger();
const statusLabel = payload.meta.status === "error"
? "龙虎榜数据暂不可用"
: payload.meta.status === "empty"
? "当日暂无公开游资明细"
: payload.meta.status === "partial"
? "当日有龙虎榜,暂无命名游资明细"
: payload.meta.status === "unavailable" ? "龙虎榜数据暂不可用" : "龙虎榜明细";
setStatus(`${statusLabel} · 龙虎榜已加载`);
} catch (error) {
showToast(error.message || "龙虎榜加载失败");
setStatus("龙虎榜加载失败");
}
}
function renderDragonTiger() {
const payload = state.dragonTiger;
if (!payload) return;
const summary = payload.summary || {};
if (state.dragonViewMode === "daily") setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`);
const status = payload.meta?.status || "empty";
const hasRecognizedTraders = (payload.traders || []).some((item) => item.identity_type === "trader" && item.recognized !== false);
const showEmptyState = !hasRecognizedTraders
&& !(payload.unclassified_seats || []).length
&& ["empty", "error", "unavailable"].includes(status);
const dailyVisible = state.dragonViewMode === "daily";
document.querySelector("#dragonProfilesContent").hidden = dailyVisible;
document.querySelector("#dragonEmptyState").hidden = !dailyVisible || !showEmptyState;
document.querySelector("#dragonDailyContent").hidden = !dailyVisible || showEmptyState;
if (showEmptyState) {
const unavailable = ["error", "unavailable"].includes(status);
setText("dragonEmptyTitle", unavailable ? "龙虎榜数据暂不可用" : `${payload.meta?.trade_date || "该交易日"} 暂无龙虎榜明细`);
setText("dragonEmptyDescription", unavailable
? "当前数据暂未完成更新,可稍后重新检查或查看前一交易日。"
: "龙虎榜明细通常在交易日盘后陆续披露,可稍后刷新或查看前一交易日。");
}
document.querySelector("#dragonSummary").innerHTML = [
["上榜游资", `${number(summary.trader_count)} 位`, ""],
["操作明细", `${number(summary.operation_count)} 条`, ""],
["席位净买入", formatMoneyMillion(summary.seat_net_buy_million), changeClass(summary.seat_net_buy_million)],
["活跃股票", `${number(summary.active_stock_count)} 只`, ""],
].map(([label, value, className]) => `<div class="dragon-metric"><span>${label}</span><strong class="${className}">${value}</strong></div>`).join("");
renderDragonTraderList();
renderUnclassifiedSeats();
}
function renderDragonTraderList() {
const payload = state.dragonTiger;
if (!payload) return;
let traders = [...(payload.traders || [])].filter((item) => item.identity_type === "trader" && item.recognized !== false);
if (state.dragonFilter === "buy") traders = traders.filter((item) => number(item.net_buy_million) > 0);
if (state.dragonFilter === "sell") traders = traders.filter((item) => number(item.net_buy_million) < 0);
if (state.dragonFilter === "unclassified") traders = [];
if (state.dragonQuery) {
traders = traders.filter((item) => {
const searchable = [
item.name,
...(item.operations || []).flatMap((operation) => [operation.code, operation.name, operation.seat_name]),
].join(" ").toLowerCase();
return searchable.includes(state.dragonQuery);
});
}
const container = document.querySelector("#dragonTraderList");
let emptyMessage = "没有符合当前条件的游资操作";
if (!Array.isArray(payload.traders)) emptyMessage = "龙虎榜数据格式暂不可用,请稍后重试";
else if (["error", "unavailable"].includes(payload.meta?.status)) emptyMessage = "龙虎榜数据暂不可用,请稍后重试";
else if (payload.meta?.status === "empty") emptyMessage = "该交易日暂无游资每日明细";
else if (payload.meta?.status === "partial") emptyMessage = `当日有 ${number(payload.summary?.official_stock_count)} 只股票上榜,但暂无可识别的游资明细`;
if (!traders.some((item) => item.id === state.selectedDragonTraderId)) {
state.selectedDragonTraderId = traders[0]?.id || "";
}
const cardMarkup = traders.map((trader, index) => {
const description = trader.description || `${number(trader.stock_count)} 只股票,${number(trader.operation_count)} 笔操作`;
return `
<article class="dragon-trader-card dealing ${trader.id === state.selectedDragonTraderId ? "selected" : ""}" data-dragon-card="${escapeHtml(trader.id)}" aria-hidden="true" style="--deal-delay:${Math.min(index * 38, 650)}ms">
<span class="dragon-card-rank">${String(index + 1).padStart(2, "0")}</span>
<span class="dragon-card-monogram">${escapeHtml(trader.name.slice(0, 2))}</span>
<span class="dragon-card-copy"><strong>${escapeHtml(trader.name)}</strong><q title="${escapeHtml(description)}">${escapeHtml(description)}</q></span>
<span class="dragon-card-stats"><small>${number(trader.stock_count)} 股 · ${number(trader.operation_count)} 笔</small><b class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</b></span>
</article>`;
}).join("");
const hitZoneMarkup = traders.map((trader) => `
<button type="button" class="dragon-card-hit-zone" data-dragon-trader="${escapeHtml(trader.id)}" aria-label="查看 ${escapeHtml(trader.name)} 当日操作" aria-pressed="${trader.id === state.selectedDragonTraderId}"></button>
`).join("");
container.innerHTML = traders.length
? `${cardMarkup}<div class="dragon-card-hit-layer">${hitZoneMarkup}</div>`
: emptyStateHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage, { className: "dragon-empty" });
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true });
});
container.querySelectorAll("[data-dragon-trader]").forEach((hitZone) => {
const setHovered = (hovered) => {
container.querySelector(`[data-dragon-card="${CSS.escape(hitZone.dataset.dragonTrader)}"]`)?.classList.toggle("hovered", hovered);
};
hitZone.addEventListener("pointerenter", () => setHovered(true));
hitZone.addEventListener("pointerleave", () => setHovered(false));
hitZone.addEventListener("focus", () => setHovered(true));
hitZone.addEventListener("blur", () => setHovered(false));
hitZone.addEventListener("click", () => {
state.selectedDragonTraderId = hitZone.dataset.dragonTrader;
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
card.classList.toggle("selected", card.dataset.dragonCard === state.selectedDragonTraderId);
});
container.querySelectorAll("[data-dragon-trader]").forEach((item) => {
item.setAttribute("aria-pressed", String(item.dataset.dragonTrader === state.selectedDragonTraderId));
});
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
});
});
requestAnimationFrame(() => layoutDragonCards(container));
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
}
function layoutDragonCards(container = document.querySelector("#dragonTraderList")) {
if (!container) return;
const cards = [...container.querySelectorAll(".dragon-trader-card")];
const hitZones = [...container.querySelectorAll(".dragon-card-hit-zone")];
if (!cards.length) return;
const compact = window.innerWidth <= 720;
const cardWidth = compact ? 148 : 176;
const available = Math.max(cardWidth, container.clientWidth - (compact ? 30 : 72));
const spread = Math.min(available - cardWidth, compact ? 310 : 1050);
const step = cards.length > 1 ? Math.min(cardWidth + 14, spread / (cards.length - 1)) : 0;
const center = (cards.length - 1) / 2;
container.style.setProperty("--dragon-card-width", `${cardWidth}px`);
cards.forEach((card, index) => {
const x = (index - center) * step;
card.style.setProperty("--card-x", `${x.toFixed(2)}px`);
card.style.setProperty("--card-rotation", "0deg");
card.style.setProperty("--card-y", "0px");
card.style.zIndex = String(index + 1);
const hitZone = hitZones[index];
if (hitZone) {
const zoneWidth = index === cards.length - 1 ? cardWidth : Math.max(18, step);
hitZone.style.left = `calc(50% + ${(x - cardWidth / 2).toFixed(2)}px)`;
hitZone.style.width = `${zoneWidth.toFixed(2)}px`;
}
});
}
function renderDragonTraderDetail(trader) {
const container = document.querySelector("#dragonTraderDetail");
if (!trader) {
container.hidden = true;
renderEmptyState(container, "选择一位游资查看操作明细", { className: "dragon-empty" });
return;
}
container.hidden = false;
container.innerHTML = `
<header class="dragon-detail-header">
<div><span>当日操作明细</span><h3>${escapeHtml(trader.name)}</h3><p>${escapeHtml(trader.description || "按当日公开龙虎榜席位汇总")}</p></div>
<dl><div><dt>买入</dt><dd class="up">${formatMoneyMillion(trader.buy_million)}</dd></div><div><dt>卖出</dt><dd class="down">${formatMoneyMillion(trader.sell_million)}</dd></div><div><dt>净额</dt><dd class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</dd></div></dl>
</header>
<div class="trader-operations table-frame tbl-wrap">
<table class="data-table tbl dragon-operation-table">
<colgroup><col class="dragon-col-index"><col class="dragon-col-stock"><col class="dragon-col-direction"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-seat"><col class="dragon-col-reason"></colgroup>
<thead><tr><th class="row-number num">序号</th><th>股票</th><th>方向</th><th class="number num">涨幅(%</th><th class="number num">买入(百万)</th><th class="number num">卖出(百万)</th><th class="number num">净额(百万)</th><th>关联席位</th><th class="reason-column">标签 / 上榜原因</th></tr></thead>
<tbody>${(trader.operations || []).map((operation, index) => `
<tr data-code="${escapeHtml(operation.code)}">
<td class="row-number num">${index + 1}</td>
<td><strong class="sname">${escapeHtml(operation.name)}</strong><span class="scode">${escapeHtml(operation.code)}</span></td>
<td><span class="direction-label ${changeClass(operation.net_buy_million)}">${escapeHtml(operation.direction)}</span></td>
<td class="number num ${operation.change == null ? "" : changeClass(operation.change)}">${operation.change == null ? "" : signed(operation.change)}</td>
<td class="number num">${operation.buy_million == null ? "" : formatNumber(operation.buy_million, 2)}</td>
<td class="number num">${operation.sell_million == null ? "" : formatNumber(operation.sell_million, 2)}</td>
<td class="number num ${operation.net_buy_million == null ? "" : changeClass(operation.net_buy_million)}">${operation.net_buy_million == null ? "" : signed(operation.net_buy_million)}</td>
<td class="seat-cell" title="${escapeHtml(operation.seat_name)}">${escapeHtml(operation.seat_name)}</td>
<td class="reason-column" title="${escapeHtml([operation.tag, operation.reason].filter((item) => item && item !== "--").join(" · "))}">${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason && operation.reason !== "--" ? operation.reason : "")}</td>
</tr>`).join("")}</tbody>
</table>
</div>`;
bindStockRows(container);
markAutoSortableHeaders(container);
}
function renderUnclassifiedSeats() {
const seats = state.dragonTiger?.unclassified_seats || [];
const canManage = state.user?.role === "admin";
document.querySelector("#dragonUnclassifiedSection").hidden = !canManage || seats.length === 0;
document.querySelector("#dragonUnclassifiedFilter").hidden = !canManage || seats.length === 0;
if (!seats.length && state.dragonFilter === "unclassified") {
state.dragonFilter = "all";
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.dragonFilter === "all");
});
renderDragonTraderList();
}
setText("unclassifiedCount", `${seats.length} 个`);
const list = document.querySelector("#unclassifiedSeatList");
list.innerHTML = seats.map((seat, index) => `
<form class="unclassified-seat-row" data-unclassified-index="${index}">
<span class="unclassified-seat-name" title="${escapeHtml(seat.seat_name)}">${escapeHtml(seat.seat_name)}</span>
<span class="unclassified-seat-stats">${number(seat.operation_count)} 笔 · ${number(seat.stock_count)} 股</span>
<strong class="${changeClass(seat.net_buy_million)}">${formatMoneyMillion(seat.net_buy_million)}</strong>
<input type="text" maxlength="50" placeholder="输入游资名" aria-label="${escapeHtml(seat.seat_name)}的游资名" required>
<button class="button" type="submit">归类</button>
</form>
`).join("") || emptyStateHtml("当前席位均已归类");
list.querySelectorAll(".unclassified-seat-row").forEach((form) => {
form.addEventListener("submit", saveSeatAlias);
});
}
function dragonIdentityLabel(type) {
return { trader: "游资", institution: "机构", channel: "通道", unclassified: "待归类" }[type] || "席位";
}
async function saveSeatAlias(event) {
event.preventDefault();
const form = event.currentTarget;
const seat = state.dragonTiger?.unclassified_seats?.[number(form.dataset.unclassifiedIndex)];
const alias = form.querySelector("input").value.trim();
if (!seat || !alias) {
showToast("请输入游资名");
return;
}
const button = form.querySelector("button");
button.disabled = true;
try {
await apiRequest("/api/seat-aliases", "POST", { seat_name: seat.seat_name, alias });
state.dragonTiger = null;
await loadDragonTiger();
showToast(`已将席位归类为 ${alias}`);
} catch (error) {
showToast(error.message);
button.disabled = false;
}
}
function bindDragonTigerEvents() {
document.querySelector("#dragonRefreshButton").addEventListener("click", () => {
if (state.dragonViewMode === "profiles") loadHotMoneyProfiles(true);
else loadDragonTiger(true);
});
document.querySelector("#dragonEmptyRefreshButton").addEventListener("click", () => loadDragonTiger(true));
document.querySelector("#dragonPreviousButton").addEventListener("click", () => shiftDate(-1));
document.querySelector("#dragonExportButton").addEventListener("click", () => {
if (state.dragonViewMode === "profiles") exportHotMoneyProfiles();
else exportDragonTiger();
});
document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => {
button.addEventListener("click", () => selectDragonViewMode(button.dataset.dragonViewMode));
});
document.querySelector("#dragonSearch").addEventListener("input", (event) => {
state.dragonQuery = event.target.value.trim().toLowerCase();
renderDragonTraderList();
});
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
button.addEventListener("click", () => {
state.dragonFilter = button.dataset.dragonFilter;
document.querySelectorAll("[data-dragon-filter]").forEach((item) => {
item.classList.toggle("active", item === button);
});
renderDragonTraderList();
});
});
document.querySelector("#hotMoneyProfileSearch").addEventListener("input", (event) => {
state.hotMoneyProfileQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderHotMoneyProfiles();
});
document.querySelector("#hotMoneyProfileList").addEventListener("click", (event) => {
const button = event.target.closest("[data-hot-money-profile]");
if (!button) return;
state.selectedHotMoneyProfileId = button.dataset.hotMoneyProfile;
renderHotMoneyProfiles();
});
window.addEventListener("resize", () => {
if (state.activeView === "dragonView") layoutDragonCards();
});
}