refactor: establish standalone application boundary
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
async function backfillData() {
|
||||
const button = document.querySelector("#backfillButton");
|
||||
button.disabled = true;
|
||||
setLoading(true, "正在回补历史交易日");
|
||||
try {
|
||||
const payload = await apiRequest("/api/backfill", "POST", {
|
||||
start_date: document.querySelector("#backfillStart").value,
|
||||
end_date: document.querySelector("#backfillEnd").value,
|
||||
});
|
||||
showToast(`历史回补完成,共处理 ${payload.results.length} 个工作日`);
|
||||
await openAdminSettings(true);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function openAdminSettings(refreshOnly = false) {
|
||||
if (state.user?.role !== "admin") return;
|
||||
if (!refreshOnly) openModalDialog(elements.adminDialog);
|
||||
const status = document.querySelector("#adminConnectionStatus");
|
||||
status.textContent = "正在读取系统状态";
|
||||
try {
|
||||
const payload = await apiRequest("/api/admin/settings");
|
||||
const data = payload.data || {};
|
||||
const ifind = data.ifind || {};
|
||||
const llm = payload.llm || {};
|
||||
const membership = payload.membership || {};
|
||||
status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
|
||||
status.classList.toggle("connected", Boolean(data.configured));
|
||||
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
|
||||
document.querySelector("#systemTokenInput").value = "";
|
||||
document.querySelector("#systemIfindTokenInput").value = "";
|
||||
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
|
||||
document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50;
|
||||
renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || "");
|
||||
renderAdminUsers(payload.users || []);
|
||||
} catch (error) {
|
||||
status.textContent = error.message || "系统配置读取失败";
|
||||
}
|
||||
}
|
||||
|
||||
function selectAdminPanel(panel) {
|
||||
const selected = ["market", "models", "members"].includes(panel) ? panel : "market";
|
||||
document.querySelector("#adminSectionSelect").value = selected;
|
||||
document.querySelectorAll("[data-admin-panel]").forEach((item) => {
|
||||
item.hidden = item.dataset.adminPanel !== selected;
|
||||
});
|
||||
}
|
||||
|
||||
function renderModelPool(models, primaryId = "", fallbackId = "") {
|
||||
state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" }));
|
||||
const container = document.querySelector("#modelPoolList");
|
||||
container.innerHTML = state.adminModels.map((item, index) => `
|
||||
<article class="model-pool-row" data-model-id="${escapeHtml(item.id)}">
|
||||
<div class="model-pool-heading"><strong>${escapeHtml(item.name || `模型 ${index + 1}`)}</strong><span>${item.configured ? "已保存密钥" : "待配置"}</span></div>
|
||||
<div class="model-pool-fields">
|
||||
<label class="form-field"><span>显示名称 *</span><input data-model-field="name" maxlength="50" value="${escapeHtml(item.name || "")}" required></label>
|
||||
<label class="form-field"><span>API Base URL *</span><input data-model-field="base_url" type="url" value="${escapeHtml(item.base_url || "https://api.openai.com/v1")}" required></label>
|
||||
<label class="form-field"><span>模型标识 *</span><input data-model-field="model" maxlength="100" value="${escapeHtml(item.model || "")}" required></label>
|
||||
<label class="form-field"><span>API Key${item.configured ? "" : " *"}</span><input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="${item.configured ? "留空保留已保存的 Key" : "输入 API Key"}" ${item.configured ? "" : "required"}></label>
|
||||
</div>
|
||||
<div class="model-test-row"><button class="button" type="button" data-test-model>测试连接</button><span class="model-test-status" aria-live="polite">未测试</span><button class="icon-button model-delete-button" type="button" data-delete-model aria-label="删除模型" title="删除模型"><i data-lucide="trash-2"></i></button></div>
|
||||
</article>
|
||||
`).join("") || emptyStateHtml("模型池为空,请先添加模型");
|
||||
updateModelRoleOptions(primaryId, fallbackId);
|
||||
container.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", () => testPlatformModel(button.closest("[data-model-id]"))));
|
||||
container.querySelectorAll("[data-delete-model]").forEach((button) => button.addEventListener("click", () => deletePlatformModel(button.closest("[data-model-id]"))));
|
||||
container.querySelectorAll("[data-model-field='name']").forEach((input) => input.addEventListener("input", updateModelRoleLabels));
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function collectModelPool() {
|
||||
const saved = new Map(state.adminModels.map((item) => [item.id, item]));
|
||||
return [...document.querySelectorAll("#modelPoolList [data-model-id]")].map((row) => ({
|
||||
id: row.dataset.modelId,
|
||||
name: row.querySelector("[data-model-field='name']").value.trim(),
|
||||
base_url: row.querySelector("[data-model-field='base_url']").value.trim(),
|
||||
model: row.querySelector("[data-model-field='model']").value.trim(),
|
||||
api_key: row.querySelector("[data-model-field='api_key']").value.trim(),
|
||||
configured: Boolean(saved.get(row.dataset.modelId)?.configured),
|
||||
}));
|
||||
}
|
||||
|
||||
function updateModelRoleOptions(primaryId = document.querySelector("#platformPrimaryModelSelect").value, fallbackId = document.querySelector("#platformFallbackModelSelect").value) {
|
||||
const models = collectModelPool();
|
||||
const options = models.map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.name || item.model || "未命名模型")}</option>`).join("");
|
||||
const primary = document.querySelector("#platformPrimaryModelSelect");
|
||||
const fallback = document.querySelector("#platformFallbackModelSelect");
|
||||
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
|
||||
fallback.innerHTML = `<option value="">不启用辅助模型</option>${options}`;
|
||||
primary.value = models.some((item) => item.id === primaryId) ? primaryId : models[0]?.id || "";
|
||||
fallback.value = models.some((item) => item.id === fallbackId) && fallbackId !== primary.value ? fallbackId : "";
|
||||
}
|
||||
|
||||
function updateModelRoleLabels() {
|
||||
updateModelRoleOptions();
|
||||
}
|
||||
|
||||
function addPlatformModel() {
|
||||
const models = collectModelPool();
|
||||
const id = `model-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
models.push({ id, name: `模型 ${models.length + 1}`, base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false });
|
||||
renderModelPool(models, document.querySelector("#platformPrimaryModelSelect").value || id, document.querySelector("#platformFallbackModelSelect").value);
|
||||
document.querySelector(`[data-model-id="${CSS.escape(id)}"] [data-model-field="name"]`)?.focus();
|
||||
}
|
||||
|
||||
function deletePlatformModel(row) {
|
||||
if (!row) return;
|
||||
const id = row.dataset.modelId;
|
||||
const primary = document.querySelector("#platformPrimaryModelSelect").value;
|
||||
const fallback = document.querySelector("#platformFallbackModelSelect").value;
|
||||
if (id === primary || id === fallback) {
|
||||
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
|
||||
return;
|
||||
}
|
||||
const models = collectModelPool().filter((item) => item.id !== id);
|
||||
renderModelPool(models, primary, fallback);
|
||||
}
|
||||
|
||||
function renderAdminUsers(users) {
|
||||
const container = document.querySelector("#adminUsersList");
|
||||
container.innerHTML = users.map((user) => {
|
||||
const admin = user.role === "admin";
|
||||
const member = Boolean(user.membership_subscribed);
|
||||
const identityLabels = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · ");
|
||||
const expiry = member
|
||||
? (user.membership_expires_at ? `有效至 ${membershipDateDisplay(user.membership_expires_at)}` : "永久有效")
|
||||
: user.membership_status === "suspended"
|
||||
? "会员已停用"
|
||||
: user.membership_status === "active" && user.membership_expires_at
|
||||
? `已于 ${membershipDateDisplay(user.membership_expires_at)} 到期`
|
||||
: "尚未开通";
|
||||
return `<article class="admin-user-row" data-admin-user="${number(user.id)}">
|
||||
<div class="admin-user-identity"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(identityLabels)}</span><small>${escapeHtml(expiry)}</small></div>
|
||||
<div class="admin-user-usage">今日调用 <b>${number(user.used_today)}</b></div>
|
||||
<form class="membership-form">
|
||||
<input type="hidden" name="user_id" value="${number(user.id)}">
|
||||
<label><span>状态</span><select name="status"><option value="inactive" ${user.membership_status === "inactive" ? "selected" : ""}>未开通</option><option value="active" ${user.membership_status === "active" ? "selected" : ""}>有效</option><option value="suspended" ${user.membership_status === "suspended" ? "selected" : ""}>停用</option></select></label>
|
||||
<label><span>开通 / 续期时长</span><select name="duration"><option value="">选择时长</option><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></label>
|
||||
<div class="membership-expiry"><span>当前到期</span><strong>${escapeHtml(expiry)}</strong></div>
|
||||
<button class="button" type="submit">应用</button>
|
||||
</form>
|
||||
</article>`;
|
||||
}).join("") || emptyStateHtml("暂无注册用户");
|
||||
container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership));
|
||||
}
|
||||
|
||||
async function saveMembership(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const data = Object.fromEntries(new FormData(form).entries());
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const payload = await apiRequest("/api/admin/membership", "POST", data);
|
||||
renderAdminUsers(payload.users || []);
|
||||
showToast("会员状态已更新");
|
||||
} catch (error) {
|
||||
showToast(error.message || "会员状态保存失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMarketSettings(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest("/api/admin/settings", "POST", {
|
||||
tushare_token: document.querySelector("#systemTokenInput").value.trim(),
|
||||
ifind_refresh_token: document.querySelector("#systemIfindTokenInput").value.trim(),
|
||||
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
|
||||
});
|
||||
document.querySelector("#systemTokenInput").value = "";
|
||||
document.querySelector("#systemIfindTokenInput").value = "";
|
||||
showToast("行情配置已保存");
|
||||
await openAdminSettings(true);
|
||||
} catch (error) {
|
||||
showToast(error.message || "系统配置保存失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveModelPool(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest("/api/admin/settings", "POST", {
|
||||
models: collectModelPool(),
|
||||
primary_model_id: document.querySelector("#platformPrimaryModelSelect").value,
|
||||
fallback_model_id: document.querySelector("#platformFallbackModelSelect").value,
|
||||
});
|
||||
showToast("模型池已保存");
|
||||
await openAdminSettings(true);
|
||||
} catch (error) {
|
||||
showToast(error.message || "模型池保存失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMembershipSettings(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest("/api/admin/settings", "POST", {
|
||||
member_daily_limit: number(document.querySelector("#memberDailyLimit").value),
|
||||
});
|
||||
showToast("会员调用额度已保存");
|
||||
await openAdminSettings(true);
|
||||
} catch (error) {
|
||||
showToast(error.message || "会员调用额度保存失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testPlatformModel(row) {
|
||||
if (!row) return;
|
||||
const button = row.querySelector("[data-test-model]");
|
||||
const status = row.querySelector(".model-test-status");
|
||||
const profile = collectModelPool().find((item) => item.id === row.dataset.modelId) || {};
|
||||
button.disabled = true;
|
||||
status.textContent = "连接中";
|
||||
try {
|
||||
const payload = await apiRequest("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile });
|
||||
status.textContent = `已连通 · ${number(payload.result.latency_ms)} ms`;
|
||||
status.className = "model-test-status success";
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
status.className = "model-test-status failure";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function bindAdminEvents() {
|
||||
document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings());
|
||||
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
|
||||
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
||||
document.querySelector("#adminSectionSelect").addEventListener("change", (event) => selectAdminPanel(event.target.value));
|
||||
document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings);
|
||||
document.querySelector("#systemModelsForm").addEventListener("submit", saveModelPool);
|
||||
document.querySelector("#membershipSettingsForm").addEventListener("submit", saveMembershipSettings);
|
||||
document.querySelector("#addPlatformModel").addEventListener("click", addPlatformModel);
|
||||
document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
window.XiaobaiAPI.configure({
|
||||
csrfToken: () => state.csrfToken,
|
||||
onUnauthorized: () => showAuthGate("登录状态已失效,请重新登录。"),
|
||||
});
|
||||
|
||||
const applicationShell = window.XiaobaiShell.create({
|
||||
state,
|
||||
pages: window.XiaobaiPages,
|
||||
motionEnabled,
|
||||
animateRows,
|
||||
refreshIcons,
|
||||
tradeDate: () => displayCompactDate(
|
||||
state.dashboard?.meta?.trade_date || document.querySelector("#tradeDate")?.value || "",
|
||||
),
|
||||
onNavigate: (viewId) => openView(viewId),
|
||||
onNavigationSync: () => toggleAccountDropdown(false),
|
||||
});
|
||||
|
||||
const pageModules = window.XiaobaiPageModules.create({
|
||||
pages: window.XiaobaiPages,
|
||||
actions: {
|
||||
closeTransientUi: () => closeStockPreview(),
|
||||
applyAccess: () => applyMembershipAccess(),
|
||||
clearAuction: () => clearAuctionTimer(),
|
||||
stopHeaven: () => {
|
||||
stopQiFieldCanvas();
|
||||
stopHeartDust();
|
||||
cancelHeavenPerformance();
|
||||
},
|
||||
loadSentiment: () => loadSentimentHistory(),
|
||||
loadRotation: () => loadRotationHistory(),
|
||||
loadAuction: () => loadAuctionCenter(),
|
||||
loadThemes: () => loadThemeLibrary(),
|
||||
loadPopularity: () => loadPopularity(),
|
||||
loadDragonTiger: () => loadDragonTiger(),
|
||||
loadReview: () => loadReviewWorkspace(),
|
||||
loadScreener: () => {
|
||||
if (hasMemberAccess() && state.dashboard) loadScreenerSetup();
|
||||
},
|
||||
loadMentor: () => {
|
||||
if (hasMemberAccess()) loadMentorSetup();
|
||||
},
|
||||
loadHeaven: () => {
|
||||
if (!hasMemberAccess()) return;
|
||||
loadHeavenSetup(false, "", document.querySelector("#heavenStockInput").value.trim());
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
function openModalDialog(dialog) {
|
||||
applicationShell.openModalDialog(dialog);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
/* Canonical CSS owner: base. Historical layers consolidated 2026-08-02. */
|
||||
@property --score {
|
||||
syntax: "<number>";
|
||||
|
||||
inherits: false;
|
||||
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
||||
margin: 0px;
|
||||
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
width: 1px;
|
||||
|
||||
height: 1px;
|
||||
|
||||
padding: 0px;
|
||||
|
||||
margin: -1px;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
clip: rect(0px, 0px, 0px, 0px);
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
html {
|
||||
width: 100%;
|
||||
|
||||
min-width: 320px;
|
||||
|
||||
min-height: 100%;
|
||||
|
||||
height: 100%;
|
||||
|
||||
margin: 0px;
|
||||
|
||||
background: var(--r2-bg);
|
||||
|
||||
color: var(--r2-ink);
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
|
||||
min-width: 320px;
|
||||
|
||||
min-height: 100%;
|
||||
|
||||
height: 100%;
|
||||
|
||||
margin: 0px;
|
||||
|
||||
padding: 0 0 var(--statusbar-height);
|
||||
|
||||
overflow-x: hidden;
|
||||
|
||||
display: block;
|
||||
|
||||
background: var(--bg);
|
||||
|
||||
color: var(--ink);
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
|
||||
font-size: 13px;
|
||||
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[tabindex]:focus-visible {
|
||||
outline: rgba(8, 127, 174, 0.48) solid 2px;
|
||||
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
dialog {
|
||||
margin: auto;
|
||||
|
||||
padding: 0px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
|
||||
border-radius: 9px;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
color: var(--text);
|
||||
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(27, 38, 49, 0.48);
|
||||
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
@media (min-width: 721px) {
|
||||
body {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
|
||||
html {
|
||||
width: 100%;
|
||||
|
||||
min-width: var(--mobile-min-width);
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
|
||||
min-width: var(--mobile-min-width);
|
||||
|
||||
padding-bottom: var(--mobile-nav-height);
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: theme-fade-out var(--motion-medium) ease both;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation: theme-fade-in var(--motion-medium) ease both;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] dialog::backdrop {
|
||||
background: var(--dialog-backdrop);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] dialog kbd {
|
||||
border-color: var(--border);
|
||||
|
||||
background: var(--surface-subtle);
|
||||
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
::after,
|
||||
::before {
|
||||
scroll-behavior: auto !important;
|
||||
|
||||
animation-duration: 0.01ms !important;
|
||||
|
||||
animation-iteration-count: 1 !important;
|
||||
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
::view-transition-new(root),
|
||||
::view-transition-old(root) {
|
||||
animation: auto ease 0s 1 normal none running none;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,380 @@
|
||||
/* Canonical CSS owner: feedback. Historical layers consolidated 2026-08-02. */
|
||||
|
||||
@keyframes row-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-message p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes breathe-core {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.86);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-line {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@keyframes line-arrive {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
transform: scaleX(0.45);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
|
||||
inset: 0px;
|
||||
|
||||
z-index: 50;
|
||||
|
||||
display: grid;
|
||||
|
||||
place-items: center;
|
||||
|
||||
background: rgba(244, 246, 248, 0.65);
|
||||
|
||||
backdrop-filter: blur(2px);
|
||||
|
||||
animation: overlay-enter var(--motion-fast) ease both;
|
||||
}
|
||||
|
||||
.loading-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes loading-box-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
transform: translateY(5px) scale(0.985);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
transform: translateY(0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 22px;
|
||||
|
||||
height: 22px;
|
||||
|
||||
border-width: 3px;
|
||||
|
||||
border-style: solid;
|
||||
|
||||
border-right-color: rgb(217, 228, 233);
|
||||
|
||||
border-bottom-color: rgb(217, 228, 233);
|
||||
|
||||
border-left-color: rgb(217, 228, 233);
|
||||
|
||||
border-image: none;
|
||||
|
||||
border-top-color: var(--blue);
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
animation: 700ms linear 0s infinite normal none running spin;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#toast.toast {
|
||||
position: fixed;
|
||||
|
||||
inset: auto 18px 48px auto;
|
||||
|
||||
z-index: 60;
|
||||
|
||||
width: max-content;
|
||||
|
||||
height: auto;
|
||||
|
||||
max-width: min(420px, -36px + 100vw);
|
||||
|
||||
padding: 11px 14px;
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
background: rgb(33, 49, 60);
|
||||
|
||||
color: rgb(255, 255, 255);
|
||||
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
opacity: 1;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
transform: none;
|
||||
|
||||
animation: toast-enter var(--motion-medium) var(--ease-out) both;
|
||||
}
|
||||
|
||||
#toast.toast[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes toast-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@keyframes command-menu-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
transform: translateY(-5px) scale(0.98);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
transform: translateY(0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
#toast.toast {
|
||||
inset: auto 10px 76px auto;
|
||||
|
||||
width: max-content;
|
||||
|
||||
height: auto;
|
||||
|
||||
max-width: calc(-20px + 100vw);
|
||||
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-box {
|
||||
min-width: 220px;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 12px;
|
||||
|
||||
border: 1px solid var(--line);
|
||||
|
||||
border-radius: 6px;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
animation: loading-box-enter var(--motion-medium) var(--ease-out) both;
|
||||
|
||||
width: min(320px, -32px + 100vw);
|
||||
|
||||
min-height: 92px;
|
||||
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: 30px minmax(0px, 1fr);
|
||||
|
||||
grid-template-rows: auto auto;
|
||||
|
||||
justify-content: stretch;
|
||||
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.loading-copy {
|
||||
min-width: 0px;
|
||||
}
|
||||
|
||||
.loading-copy strong {
|
||||
display: block;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.loading-copy small {
|
||||
display: block;
|
||||
|
||||
margin-top: 4px;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
font-size: 11px;
|
||||
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.loading-progress {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
height: 3px;
|
||||
|
||||
margin-top: 13px;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
border-radius: 2px;
|
||||
|
||||
background: rgb(229, 234, 238);
|
||||
}
|
||||
|
||||
.loading-progress i {
|
||||
width: 44%;
|
||||
|
||||
height: 100%;
|
||||
|
||||
display: block;
|
||||
|
||||
background: var(--action);
|
||||
|
||||
animation: 1.25s ease-in-out 0s infinite normal none running loading-progress;
|
||||
}
|
||||
|
||||
.loading-overlay[data-context="screener"] .loading-box {
|
||||
width: min(430px, -32px + 100vw);
|
||||
|
||||
min-height: 138px;
|
||||
|
||||
padding: 24px 26px;
|
||||
|
||||
border-color: rgb(200, 214, 228);
|
||||
|
||||
box-shadow: rgba(24, 34, 45, 0.18) 0px 20px 50px;
|
||||
}
|
||||
|
||||
.loading-overlay[data-context="screener"] .spinner {
|
||||
width: 28px;
|
||||
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.loading-overlay[data-context="screener"] .loading-copy strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading-overlay[data-context="screener"] .loading-copy small {
|
||||
margin-top: 7px;
|
||||
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes loading-progress {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes account-menu-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
transform: translateY(-5px) scale(0.98);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
transform: translateY(0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes assistant-caret {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes xb-view-enter {
|
||||
0% {
|
||||
opacity: 0.35;
|
||||
|
||||
transform: translateY(3px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
|
||||
min-height: 170px;
|
||||
|
||||
display: grid;
|
||||
|
||||
place-items: center;
|
||||
|
||||
padding: 24px;
|
||||
|
||||
color: var(--text-tertiary);
|
||||
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
@keyframes stage18-dialog-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
transform: translateY(7px) scale(0.992);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
transform: translateY(0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] :is(.sub, .muted, .dtag, .table-muted, small, .empty-state) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/* Canonical CSS owner: navigation. Historical layers consolidated 2026-08-02. */
|
||||
.segment:last-child {
|
||||
border-right: 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.toolbar-controls {
|
||||
width: 100%;
|
||||
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.segmented {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment {
|
||||
min-width: 0px;
|
||||
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.toolbar-controls {
|
||||
width: 100%;
|
||||
|
||||
align-items: stretch;
|
||||
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
min-height: 40px;
|
||||
|
||||
padding: 0px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-controls {
|
||||
display: flex;
|
||||
|
||||
margin-left: auto;
|
||||
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
height: 34px;
|
||||
|
||||
display: flex;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
min-height: 36px;
|
||||
|
||||
padding: 2px;
|
||||
|
||||
border: 0px;
|
||||
|
||||
border-radius: 7px;
|
||||
|
||||
background: rgb(236, 239, 244);
|
||||
}
|
||||
|
||||
.segment {
|
||||
background: var(--surface);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
min-height: 28px;
|
||||
|
||||
padding: 0px 10px;
|
||||
|
||||
border: 0px;
|
||||
|
||||
border-radius: 5px;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.segment.active {
|
||||
background: var(--surface-raised);
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
box-shadow: rgba(16, 24, 40, 0.09) 0px 1px 2px;
|
||||
}
|
||||
|
||||
.redesigned-page-head .toolbar-controls {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.redesigned-page-head .segmented {
|
||||
min-height: 28px;
|
||||
|
||||
display: inline-flex;
|
||||
|
||||
gap: 2px;
|
||||
|
||||
padding: 2px;
|
||||
|
||||
border: 0px;
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
background: rgb(243, 244, 246);
|
||||
}
|
||||
|
||||
.redesigned-page-head .segment {
|
||||
min-height: 24px;
|
||||
|
||||
padding: 4px 12px;
|
||||
|
||||
border: 0px;
|
||||
|
||||
border-radius: 6px;
|
||||
|
||||
background: transparent;
|
||||
|
||||
color: var(--r2-sub);
|
||||
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.redesigned-page-head .segment.active {
|
||||
background: rgb(255, 255, 255);
|
||||
|
||||
color: var(--r2-ink);
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
box-shadow: rgba(0, 0, 0, 0.08) 0px 1px 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) {
|
||||
.redesigned-page-head .toolbar-controls {
|
||||
width: 100%;
|
||||
|
||||
margin-left: 0px;
|
||||
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 2px;
|
||||
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
|
||||
padding: 0px 14px;
|
||||
}
|
||||
|
||||
.tabs .tab {
|
||||
padding: 10px 14px;
|
||||
|
||||
font-size: 13px;
|
||||
|
||||
color: var(--sub);
|
||||
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
margin-bottom: -1px;
|
||||
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tabs .tab .n {
|
||||
font-size: 11px;
|
||||
|
||||
color: var(--faint);
|
||||
|
||||
margin-left: 3px;
|
||||
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tabs .tab.active {
|
||||
color: var(--blue);
|
||||
|
||||
border-bottom-color: var(--blue);
|
||||
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs .tab.active .n {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
|
||||
background: rgb(243, 244, 246);
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
padding: 2px;
|
||||
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@media (min-width: 521px) {
|
||||
.segment {
|
||||
min-width: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 721px) {
|
||||
.toolbar-controls {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
/* Canonical CSS owner: tables. Historical layers consolidated 2026-08-02. */
|
||||
.data-table th[data-sort] {
|
||||
cursor: pointer;
|
||||
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.data-table th[data-sort]:hover {
|
||||
background: rgb(227, 237, 242);
|
||||
|
||||
color: var(--blue-dark);
|
||||
}
|
||||
|
||||
.data-table th.sort-asc::after {
|
||||
content: " ↑";
|
||||
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.data-table th.sort-desc::after {
|
||||
content: " ↓";
|
||||
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.data-table tbody tr td {
|
||||
transition: background-color var(--motion-fast) ease, color var(--motion-fast) ease;
|
||||
}
|
||||
|
||||
.data-table tbody tr.row-enter {
|
||||
animation-delay: var(--row-delay, 0ms);
|
||||
}
|
||||
|
||||
.data-table tbody tr.row-pending {
|
||||
opacity: 0;
|
||||
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.data-table .number {
|
||||
text-align: right;
|
||||
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.data-table .row-number {
|
||||
width: 38px;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-action {
|
||||
padding: 3px 7px;
|
||||
|
||||
border: 0px;
|
||||
|
||||
background: transparent;
|
||||
|
||||
color: var(--blue);
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-muted {
|
||||
color: var(--text-muted);
|
||||
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-table:not(#limitTable) th[data-auto-sort] {
|
||||
cursor: pointer;
|
||||
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.data-table:not(#limitTable) th[data-auto-sort]:hover {
|
||||
background: rgb(227, 237, 242);
|
||||
|
||||
color: var(--action-hover);
|
||||
}
|
||||
|
||||
.data-table th[data-auto-sort].sort-asc::after {
|
||||
content: " ↑";
|
||||
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
.data-table th[data-auto-sort].sort-desc::after {
|
||||
content: " ↓";
|
||||
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.data-table tbody tr.row-pending {
|
||||
opacity: 1;
|
||||
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.table-frame {
|
||||
max-height: 520px;
|
||||
|
||||
border-right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
background: var(--surface);
|
||||
|
||||
text-align: left;
|
||||
|
||||
height: 42px;
|
||||
|
||||
padding: 8px 11px;
|
||||
|
||||
border-right: 0px;
|
||||
|
||||
border-bottom: 1px solid rgb(232, 237, 241);
|
||||
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
|
||||
position: sticky;
|
||||
|
||||
top: 0px;
|
||||
|
||||
z-index: 2;
|
||||
|
||||
padding: 8px 11px;
|
||||
|
||||
border-right: 0px;
|
||||
|
||||
border-bottom: 1px solid rgb(232, 237, 241);
|
||||
|
||||
line-height: 1.4;
|
||||
|
||||
height: 38px;
|
||||
|
||||
background: rgb(244, 247, 249);
|
||||
|
||||
color: rgb(82, 98, 115);
|
||||
|
||||
font-size: 11.5px;
|
||||
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-table tbody tr.selected td {
|
||||
background: rgb(237, 245, 255);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.data-table {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
height: 44px;
|
||||
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
height: 44px;
|
||||
|
||||
padding: 8px 10px;
|
||||
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.main-grid > .table-frame {
|
||||
min-height: 460px;
|
||||
|
||||
border-right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.data-table {
|
||||
border-spacing: 0px;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
color: rgb(38, 51, 64);
|
||||
|
||||
width: 100%;
|
||||
|
||||
border-collapse: collapse;
|
||||
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child td {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
cursor: pointer;
|
||||
|
||||
transition: background var(--duration-fast);
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover td {
|
||||
background: rgb(248, 250, 255);
|
||||
}
|
||||
|
||||
.data-table .stock-name {
|
||||
color: var(--text-primary);
|
||||
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.main-grid > .table-frame {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
border-radius: var(--card-radius);
|
||||
}
|
||||
|
||||
.table-frame {
|
||||
position: relative;
|
||||
|
||||
min-width: 0px;
|
||||
|
||||
max-height: calc(-285px + 100vh);
|
||||
|
||||
border: 1px solid var(--card-border);
|
||||
|
||||
background: var(--card-bg);
|
||||
|
||||
box-shadow: var(--card-shadow);
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
border-radius: var(--card-radius);
|
||||
}
|
||||
|
||||
.data-table thead th {
|
||||
padding: 0px 11px;
|
||||
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
background: var(--surface-subtle);
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
font-weight: 650;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
height: 36px;
|
||||
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.data-table tbody td {
|
||||
padding: 6px 11px;
|
||||
|
||||
border-bottom: 1px solid rgb(237, 240, 244);
|
||||
|
||||
color: var(--xb-gray-700);
|
||||
|
||||
height: 41px;
|
||||
}
|
||||
|
||||
.main-grid .data-table thead th {
|
||||
height: 34px;
|
||||
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.main-grid .data-table tbody td {
|
||||
height: 40px;
|
||||
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) tbody tr {
|
||||
transition: background-color 150ms;
|
||||
}
|
||||
|
||||
.tbl-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tbl thead th {
|
||||
position: sticky;
|
||||
|
||||
top: 0px;
|
||||
|
||||
background: rgb(248, 250, 252);
|
||||
|
||||
color: var(--sub);
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
font-size: 12px;
|
||||
|
||||
text-align: left;
|
||||
|
||||
padding: 8px 12px;
|
||||
|
||||
border-bottom: 1px solid var(--line);
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tbl thead th.sortable {
|
||||
cursor: pointer;
|
||||
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tbl thead th.sortable:hover {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.tbl thead th .arr {
|
||||
font-size: 9px;
|
||||
|
||||
color: var(--faint);
|
||||
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.tbl thead th.sorted .arr {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.tbl tbody td {
|
||||
padding: 9px 12px;
|
||||
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tbl tbody tr:hover {
|
||||
background: rgb(248, 250, 255);
|
||||
}
|
||||
|
||||
.tbl.compact tbody td {
|
||||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
.tbl thead th.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tbl-tools {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
padding: 9px 14px;
|
||||
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tbl-tools .right {
|
||||
margin-left: auto;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) th {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) th.row-number {
|
||||
width: var(--col-rank);
|
||||
}
|
||||
|
||||
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) th:nth-child(2) {
|
||||
width: var(--col-stock);
|
||||
}
|
||||
|
||||
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) th.number {
|
||||
width: var(--col-number);
|
||||
}
|
||||
|
||||
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) thead th.num {
|
||||
text-align: right;
|
||||
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] :is(table, .data-table, .tbl) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] :is(table thead th, .data-table thead th, .tbl thead th) {
|
||||
border-color: var(--border);
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] :is(table tbody td, .data-table tbody td, .tbl tbody td) {
|
||||
border-color: var(--line-soft);
|
||||
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] :is(table tbody tr:hover td, .data-table tbody tr:hover td, .tbl tbody tr:hover td) {
|
||||
background: var(--action-soft);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
const {
|
||||
clamp,
|
||||
displayCompactDate,
|
||||
escapeHtml,
|
||||
formatNumber,
|
||||
formatTimestamp,
|
||||
localDateString,
|
||||
number,
|
||||
parseLocalDate,
|
||||
todayString,
|
||||
} = window.XiaobaiUI;
|
||||
|
||||
const {
|
||||
emptyStateHtml,
|
||||
renderEmptyState,
|
||||
} = window.XiaobaiComponents;
|
||||
|
||||
|
||||
const state = window.XiaobaiState.create({
|
||||
session: {
|
||||
user: null,
|
||||
csrfToken: "",
|
||||
authMode: "login",
|
||||
started: false,
|
||||
activeView: "sentimentCycleView",
|
||||
dashboardLoading: false,
|
||||
dashboardRequestSequence: 0,
|
||||
dashboardRequestDate: "",
|
||||
adminModels: [],
|
||||
globalSearchResults: [],
|
||||
globalSearchActiveIndex: -1,
|
||||
globalSearchRequestSequence: 0,
|
||||
},
|
||||
market: {
|
||||
dashboard: null,
|
||||
filter: "all",
|
||||
query: "",
|
||||
sortKey: "streak",
|
||||
sortDirection: "desc",
|
||||
brokenQuery: "",
|
||||
brokenSortKey: "",
|
||||
brokenSortDirection: "desc",
|
||||
downQuery: "",
|
||||
downSortKey: "",
|
||||
downSortDirection: "asc",
|
||||
yesterdayFilter: "all",
|
||||
yesterdayQuery: "",
|
||||
yesterdaySortKey: "",
|
||||
yesterdaySortDirection: "desc",
|
||||
dragonTiger: null,
|
||||
dragonViewMode: "daily",
|
||||
dragonFilter: "all",
|
||||
dragonQuery: "",
|
||||
selectedDragonTraderId: "",
|
||||
hotMoneyProfiles: null,
|
||||
hotMoneyProfileQuery: "",
|
||||
selectedHotMoneyProfileId: "",
|
||||
rotationHistory: null,
|
||||
rotationHistoryKey: "",
|
||||
rotationSelectedSector: "",
|
||||
rotationSelectedDate: "",
|
||||
rotationMembers: null,
|
||||
rotationMembersKey: "",
|
||||
rotationMembersLoading: false,
|
||||
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
|
||||
rotationLoading: false,
|
||||
auctionData: null,
|
||||
auctionDataset: "focus",
|
||||
auctionFilter: "all",
|
||||
auctionQuery: "",
|
||||
auctionSortKey: "attention_score",
|
||||
auctionSortDirection: "desc",
|
||||
auctionLoading: false,
|
||||
auctionTimer: null,
|
||||
themeLibrary: null,
|
||||
themeQuery: "",
|
||||
selectedThemeCode: "",
|
||||
themeDetail: null,
|
||||
themeLoading: false,
|
||||
popularityData: null,
|
||||
popularitySource: "combined",
|
||||
popularityQuery: "",
|
||||
popularityLoading: false,
|
||||
expandedLadderLevels: new Set(),
|
||||
ladderSortMode: "time",
|
||||
sentimentHistory: null,
|
||||
sentimentRange: 20,
|
||||
sentimentHistoryKey: "",
|
||||
sentimentLoading: false,
|
||||
},
|
||||
details: {
|
||||
stockDetail: null,
|
||||
activeStock: null,
|
||||
stockDetailChartMode: "daily",
|
||||
stockDetailIntraday: null,
|
||||
stockDetailRequestSequence: 0,
|
||||
entityDetailItem: null,
|
||||
entityDetailPayload: null,
|
||||
entityDetailChartMode: "daily",
|
||||
entityDetailIntraday: null,
|
||||
entityDetailRequestSequence: 0,
|
||||
stockPreviewCode: "",
|
||||
stockPreviewType: "stock",
|
||||
stockPreviewItem: null,
|
||||
stockPreviewPayload: null,
|
||||
stockPreviewChart: "daily",
|
||||
stockPreviewFallback: null,
|
||||
initialStockOpened: false,
|
||||
},
|
||||
review: {
|
||||
watchlist: [],
|
||||
watchlistSelection: null,
|
||||
watchlistSearchResults: [],
|
||||
watchlistSearchRequestSequence: 0,
|
||||
editingDailyNoteId: 0,
|
||||
notes: [],
|
||||
tradeEntries: [],
|
||||
tradeSummary: {},
|
||||
editingTradeId: 0,
|
||||
alerts: [],
|
||||
alertFilter: "all",
|
||||
alertUnreadCount: 0,
|
||||
assistantMessages: [],
|
||||
assistantLoading: false,
|
||||
assistantController: null,
|
||||
},
|
||||
screener: {
|
||||
screenerSetup: null,
|
||||
screenerSetupKey: "",
|
||||
screenerSetupRequestKey: "",
|
||||
screenerSetupPromise: null,
|
||||
selectedRegime: "",
|
||||
selectedStrategy: null,
|
||||
customStrategyDraft: null,
|
||||
screenerRunning: false,
|
||||
screenerRunningMode: "",
|
||||
screenerResults: { smart: null, curated: null, quant: null },
|
||||
screenerResultContexts: { smart: null, curated: null, quant: null },
|
||||
screenerResultStore: {},
|
||||
screenerTracking: null,
|
||||
screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
|
||||
? localStorage.getItem("xiaobaiScreenerMode")
|
||||
: "smart",
|
||||
curatedCategory: "全部",
|
||||
curatedSchool: "全部",
|
||||
curatedQuery: "",
|
||||
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
|
||||
selectedCuratedStrategyId: 0,
|
||||
quantFilters: [],
|
||||
quantScores: [],
|
||||
screenerMobileView: "strategy",
|
||||
},
|
||||
mentor: {
|
||||
mentorSetup: null,
|
||||
selectedMentorId: "",
|
||||
mentorMessages: [],
|
||||
mentorLoading: false,
|
||||
mentorQuery: "",
|
||||
mentorGrade: "all",
|
||||
mentorDirectoryOpen: false,
|
||||
mentorSortMode: false,
|
||||
mentorSavingPreferences: false,
|
||||
mentorController: null,
|
||||
},
|
||||
heaven: {
|
||||
heavenSetup: null,
|
||||
heavenManualData: null,
|
||||
personalField: null,
|
||||
heavenPanel: "trend",
|
||||
heavenInterpretations: { trend: "", fortune: "", heart: "" },
|
||||
heavenReadingMode: "trend",
|
||||
heavenReadingTab: "current",
|
||||
heavenReadingHistory: { trend: [], fortune: [], heart: [] },
|
||||
heavenReadingSelectedId: 0,
|
||||
heavenReadingLoading: false,
|
||||
heavenReadingError: "",
|
||||
heartStage: "intro",
|
||||
heartTimer: null,
|
||||
heartSeconds: HEART_BREATH_TOTAL_MS / 1000,
|
||||
heartBreathingEndsAt: 0,
|
||||
heartLines: [],
|
||||
heartThrows: [],
|
||||
heartHexagram: null,
|
||||
heartCurtainTimer: null,
|
||||
heartStageToken: 0,
|
||||
heartRevealToken: 0,
|
||||
heavenPerformanceKey: "",
|
||||
heavenPerformancePanels: new Set(),
|
||||
heavenPerformanceActive: "",
|
||||
heavenRequestSequence: 0,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const elements = {
|
||||
tradeDate: document.querySelector("#tradeDate"),
|
||||
loading: document.querySelector("#loadingOverlay"),
|
||||
toast: document.querySelector("#toast"),
|
||||
stockDialog: document.querySelector("#stockDialog"),
|
||||
tradeLogDialog: document.querySelector("#tradeLogDialog"),
|
||||
watchlistDialog: document.querySelector("#watchlistDialog"),
|
||||
alertsDialog: document.querySelector("#alertsDialog"),
|
||||
assistantDialog: document.querySelector("#assistantDialog"),
|
||||
heavenReadingDialog: document.querySelector("#heavenReadingDialog"),
|
||||
globalSearchDialog: document.querySelector("#globalSearchDialog"),
|
||||
globalSearchInput: document.querySelector("#globalSearchInput"),
|
||||
globalSearchResults: document.querySelector("#globalSearchResults"),
|
||||
entityDetailDialog: document.querySelector("#entityDetailDialog"),
|
||||
entityDetailChart: document.querySelector("#entityDetailChart"),
|
||||
settingsDialog: document.querySelector("#settingsDialog"),
|
||||
adminDialog: document.querySelector("#adminDialog"),
|
||||
priceChart: document.querySelector("#priceChart"),
|
||||
stockPreview: document.querySelector("#stockPreview"),
|
||||
stockPreviewBackdrop: document.querySelector("#stockPreviewBackdrop"),
|
||||
stockPreviewChart: document.querySelector("#stockPreviewChart"),
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
async function loadDashboard(force = false, background = false, showOverlay = true) {
|
||||
const requestedDate = elements.tradeDate.value;
|
||||
if (state.dashboardLoading && state.dashboardRequestDate === requestedDate) return;
|
||||
state.dashboardLoading = true;
|
||||
state.dashboardRequestDate = requestedDate;
|
||||
const requestSequence = ++state.dashboardRequestSequence;
|
||||
if (force) stockPreviewCache.clear();
|
||||
if (!background && showOverlay) {
|
||||
setLoading(true, "正在加载市场数据");
|
||||
setStatus("正在加载市场数据");
|
||||
} else if (!background) {
|
||||
setStatus("正在刷新行情");
|
||||
}
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
if (force) query.set("force", "1");
|
||||
const payload = await apiRequest(`/api/dashboard?${query}`);
|
||||
if (
|
||||
requestSequence !== state.dashboardRequestSequence
|
||||
|| requestedDate !== elements.tradeDate.value
|
||||
) return;
|
||||
applyDashboard(payload, background);
|
||||
} catch (error) {
|
||||
if (background) {
|
||||
setStatus("实时刷新暂时中断,正在等待重试");
|
||||
} else {
|
||||
showToast(error.message || "无法连接本地服务");
|
||||
setStatus("加载失败");
|
||||
}
|
||||
} finally {
|
||||
if (requestSequence === state.dashboardRequestSequence) {
|
||||
state.dashboardLoading = false;
|
||||
state.dashboardRequestDate = "";
|
||||
if (!background && showOverlay) setLoading(false);
|
||||
updateDateButtons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startAdminRefresh() {
|
||||
const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean);
|
||||
buttons.forEach((button) => { button.disabled = true; });
|
||||
try {
|
||||
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value });
|
||||
showToast(payload.message || "后台刷新已提交");
|
||||
setStatus("后台刷新运行中,当前页面保持不变");
|
||||
} catch (error) {
|
||||
showToast(error.message || "后台刷新启动失败");
|
||||
} finally {
|
||||
buttons.forEach((button) => { button.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
function applyDashboard(payload, background = false) {
|
||||
state.dashboard = payload;
|
||||
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
|
||||
elements.tradeDate.value = selectedDate;
|
||||
document.querySelector("#qiObservationDate").value = selectedDate;
|
||||
document.querySelector("#journalDate").value = selectedDate;
|
||||
renderDashboard();
|
||||
setStatus(`${dashboardSourceLabel(payload.meta)} · 数据已更新`);
|
||||
if (!background) {
|
||||
if (state.activeView === "dragonView") loadDragonTiger();
|
||||
if (state.activeView === "screenerView") loadScreenerSetup();
|
||||
if (state.activeView === "screenerTrackingView") loadScreenerTracking(true);
|
||||
if (state.activeView === "mentorView") loadMentorSetup(true);
|
||||
if (state.activeView === "heavenView") loadHeavenSetup(true);
|
||||
if (state.activeView === "sentimentCycleView") loadSentimentHistory(true);
|
||||
if (state.activeView === "rotationView") loadRotationHistory(true);
|
||||
if (state.activeView === "auctionView") loadAuctionCenter(true);
|
||||
if (state.activeView === "themeLibraryView") loadThemeLibrary(true);
|
||||
if (state.activeView === "popularityView") loadPopularity(true);
|
||||
}
|
||||
const requestedStock = new URLSearchParams(window.location.search).get("stock");
|
||||
if (!state.initialStockOpened && /^\d{6}$/.test(requestedStock || "")) {
|
||||
state.initialStockOpened = true;
|
||||
openStock(requestedStock);
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardSourceLabel(meta = {}) {
|
||||
if (meta.realtime && !["closed", "after_hours"].includes(String(meta.market_status || ""))) return "盘中行情";
|
||||
if (meta.carried_forward) return "最近收盘行情";
|
||||
if (meta.market_status === "historical") return "历史行情";
|
||||
return "收盘行情";
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
const { meta, overview, ladders, sectors } = state.dashboard;
|
||||
animateMetric("tapeUp", overview.up_count, (value) => Math.round(value));
|
||||
animateMetric("tapeDown", overview.down_count, (value) => Math.round(value));
|
||||
setText("tapeLimit", `${overview.limit_up_count} / 跌停 ${overview.limit_down_count}`);
|
||||
animateMetric("tapeAmount", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
|
||||
animateMetric("limitUpMetric", overview.limit_up_count, (value) => `${Math.round(value)} 家`);
|
||||
animateMetric("limitDownMetric", overview.limit_down_count, (value) => `${Math.round(value)} 家`);
|
||||
animateMetric("brokenMetric", overview.broken_count, (value) => `${Math.round(value)} 家`);
|
||||
animateMetric("sealRateMetric", overview.seal_rate, (value) => `${formatNumber(value, 1)}%`);
|
||||
animateMetric("amountMetric", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
|
||||
setText("dataDateMetric", dashboardDataTimestamp(meta));
|
||||
animateMetric("sentimentScore", overview.sentiment_score, (value) => Math.round(value));
|
||||
setText("sentimentText", sentimentLabel(overview.sentiment_score));
|
||||
updateSentimentGauge(overview.sentiment_score);
|
||||
setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
|
||||
|
||||
renderLimitTable();
|
||||
renderLadderMini(ladders || []);
|
||||
renderSectorMini(sectors || []);
|
||||
renderBrokenTable(state.dashboard.broken || []);
|
||||
renderDownTable(state.dashboard.down_limits || []);
|
||||
renderYesterdayTable(state.dashboard.yesterday_limits || []);
|
||||
renderPerformance(state.dashboard.limit_performance || []);
|
||||
renderLadderBoard(ladders || []);
|
||||
renderRotationMembers();
|
||||
}
|
||||
|
||||
|
||||
function shiftDate(delta) {
|
||||
const current = parseLocalDate(elements.tradeDate.value);
|
||||
current.setDate(current.getDate() + delta);
|
||||
const next = localDateString(current);
|
||||
if (next > todayString()) return;
|
||||
elements.tradeDate.value = next;
|
||||
state.heavenManualData = null;
|
||||
document.querySelector("#qiObservationDate").value = next;
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
function updateDateButtons() {
|
||||
document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function sentimentLabel(score) {
|
||||
const value = number(score);
|
||||
if (value >= 80) return "情绪高涨";
|
||||
if (value >= 60) return "情绪偏强";
|
||||
if (value >= 40) return "情绪中性";
|
||||
if (value >= 20) return "情绪偏弱";
|
||||
return "情绪冰点";
|
||||
}
|
||||
|
||||
|
||||
function dashboardDataTimestamp(meta = {}) {
|
||||
const tradeDate = displayCompactDate(meta.trade_date);
|
||||
if (tradeDate === "--") return "--";
|
||||
const intraday = tradeDate === todayString() && Boolean(meta.realtime) && !["closed", "after_hours"].includes(String(meta.market_status || ""));
|
||||
if (intraday) {
|
||||
const updated = new Date(meta.updated_at);
|
||||
if (!Number.isNaN(updated.getTime())) {
|
||||
const dateText = `${updated.getFullYear()}-${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")}`;
|
||||
const timeText = updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
return `${dateText} ${timeText}`;
|
||||
}
|
||||
}
|
||||
return `${tradeDate} 15:00`;
|
||||
}
|
||||
|
||||
|
||||
function updateSentimentGauge(rawScore) {
|
||||
const gauge = document.querySelector("#sentimentGauge");
|
||||
if (!gauge) return;
|
||||
const score = clamp(rawScore, 0, 100);
|
||||
const previous = Number(gauge.dataset.score);
|
||||
gauge.dataset.score = String(score);
|
||||
gauge.style.setProperty("--score", score);
|
||||
if (!motionEnabled() || !Number.isFinite(previous) || Math.abs(previous - score) < 15) return;
|
||||
gauge.classList.remove("sentiment-pulse");
|
||||
void gauge.offsetWidth;
|
||||
gauge.classList.add("sentiment-pulse");
|
||||
gauge.addEventListener("animationend", () => gauge.classList.remove("sentiment-pulse"), { once: true });
|
||||
}
|
||||
|
||||
function bindDashboardEvents() {
|
||||
document.querySelector("#refreshButton").addEventListener("click", async (event) => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await loadDashboard(false, false, false);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
document.querySelector("#syncButton").addEventListener("click", startAdminRefresh);
|
||||
elements.tradeDate.addEventListener("change", () => {
|
||||
state.dashboardRequestSequence += 1;
|
||||
state.heavenRequestSequence += 1;
|
||||
state.heavenManualData = null;
|
||||
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
|
||||
loadDashboard();
|
||||
});
|
||||
document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1));
|
||||
document.querySelector("#nextDate").addEventListener("click", () => shiftDate(1));
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:8905-9051 */
|
||||
function exportStocks() {
|
||||
exportRows("涨停池", getVisibleStocks(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["连板", "streak"], ["涨幅%", "change"],
|
||||
@@ -139,5 +138,3 @@ function csvCell(value) {
|
||||
if (/^[=+\-@]/.test(text)) text = `'${text}`;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:8905-9051 */
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
const metricAnimationFrames = new WeakMap();
|
||||
|
||||
let rowAnimationObserver = null;
|
||||
|
||||
|
||||
function changeClass(value) {
|
||||
return number(value) > 0 ? "up" : number(value) < 0 ? "down" : "";
|
||||
}
|
||||
|
||||
|
||||
function streakLabel(streak) {
|
||||
const value = Math.max(1, number(streak));
|
||||
return value === 1 ? "首板" : `${value}板`;
|
||||
}
|
||||
|
||||
function signed(value) {
|
||||
const parsed = number(value);
|
||||
return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`;
|
||||
}
|
||||
|
||||
|
||||
function formatMoneyMillion(value) {
|
||||
const parsed = number(value);
|
||||
const sign = parsed > 0 ? "+" : "";
|
||||
if (Math.abs(parsed) >= 100) return `${sign}${formatNumber(parsed / 100, 2)} 亿`;
|
||||
return `${sign}${formatNumber(parsed * 100, 0)} 万`;
|
||||
}
|
||||
|
||||
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
|
||||
return window.XiaobaiAPI.request(url, method, body, requestOptions);
|
||||
}
|
||||
|
||||
function setLoading(loading, text = "正在加载复盘数据", context = "default") {
|
||||
elements.loading.hidden = !loading;
|
||||
elements.loading.dataset.context = loading ? context : "default";
|
||||
setText("loadingTitle", text);
|
||||
setText(
|
||||
"loadingHint",
|
||||
context === "screener"
|
||||
? "正在完成因子筛选、候选排序与历史样本回测,这通常需要一点时间"
|
||||
: "请稍候",
|
||||
);
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
applicationShell.setStatus(text);
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function showToast(message) {
|
||||
clearTimeout(toastTimer);
|
||||
elements.toast.textContent = message;
|
||||
elements.toast.hidden = false;
|
||||
toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 3600);
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) element.textContent = value;
|
||||
}
|
||||
|
||||
function motionEnabled() {
|
||||
return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
function refreshIcons() {
|
||||
if (!window.lucide?.createIcons) return;
|
||||
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
}
|
||||
|
||||
function toggleHeaderCommandMenu(force) {
|
||||
applicationShell.toggleHeaderCommandMenu(force);
|
||||
}
|
||||
|
||||
|
||||
function animateMetric(id, rawValue, formatter = (value) => value) {
|
||||
const element = document.getElementById(id);
|
||||
const target = Number(rawValue);
|
||||
if (!element || !Number.isFinite(target)) {
|
||||
setText(id, formatter(rawValue));
|
||||
return;
|
||||
}
|
||||
const storedValue = Number(element.dataset.metricValue);
|
||||
const previous = Number.isFinite(storedValue) ? storedValue : 0;
|
||||
element.dataset.metricValue = String(target);
|
||||
const existingFrame = metricAnimationFrames.get(element);
|
||||
if (existingFrame) cancelAnimationFrame(existingFrame);
|
||||
if (!motionEnabled() || previous === target) {
|
||||
element.textContent = formatter(target);
|
||||
return;
|
||||
}
|
||||
element.classList.remove("metric-changed");
|
||||
void element.offsetWidth;
|
||||
element.classList.add("metric-changed");
|
||||
const startedAt = performance.now();
|
||||
const duration = 560;
|
||||
const update = (now) => {
|
||||
const progress = Math.min(1, (now - startedAt) / duration);
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
element.textContent = formatter(previous + (target - previous) * eased);
|
||||
if (progress < 1) {
|
||||
metricAnimationFrames.set(element, requestAnimationFrame(update));
|
||||
} else {
|
||||
element.textContent = formatter(target);
|
||||
metricAnimationFrames.delete(element);
|
||||
setTimeout(() => element.classList.remove("metric-changed"), 80);
|
||||
}
|
||||
};
|
||||
metricAnimationFrames.set(element, requestAnimationFrame(update));
|
||||
}
|
||||
|
||||
function animateRows(container) {
|
||||
if (!container) return;
|
||||
const rows = [...container.children].filter((item) => item.matches("tr, [data-code]"));
|
||||
if (!motionEnabled()) {
|
||||
rows.forEach((row) => row.classList.remove("row-pending", "row-enter"));
|
||||
return;
|
||||
}
|
||||
const unseenRows = rows.filter((row) => row.dataset.motionSeen !== "1");
|
||||
unseenRows.slice(0, 12).forEach((row, index) => {
|
||||
row.dataset.motionSeen = "1";
|
||||
row.classList.remove("row-pending", "row-enter");
|
||||
row.style.setProperty("--row-delay", `${index * 24}ms`);
|
||||
requestAnimationFrame(() => row.classList.add("row-enter"));
|
||||
row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true });
|
||||
});
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
unseenRows.slice(12).forEach((row) => { row.dataset.motionSeen = "1"; });
|
||||
return;
|
||||
}
|
||||
if (!rowAnimationObserver) {
|
||||
rowAnimationObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
const row = entry.target;
|
||||
rowAnimationObserver.unobserve(row);
|
||||
row.dataset.motionSeen = "1";
|
||||
row.classList.remove("row-pending");
|
||||
row.style.setProperty("--row-delay", "0ms");
|
||||
requestAnimationFrame(() => row.classList.add("row-enter"));
|
||||
row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true });
|
||||
});
|
||||
}, { threshold: 0.08, rootMargin: "0px 0px 40px 0px" });
|
||||
}
|
||||
unseenRows.slice(12).forEach((row) => {
|
||||
row.classList.add("row-pending");
|
||||
rowAnimationObserver.observe(row);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMotion(duration) {
|
||||
return new Promise((resolve) => setTimeout(resolve, motionEnabled() ? duration : 0));
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
function selectAuthMode(mode) {
|
||||
state.authMode = mode === "register" ? "register" : "login";
|
||||
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.authMode === state.authMode);
|
||||
});
|
||||
const registering = state.authMode === "register";
|
||||
document.querySelector("#authConfirmField").hidden = !registering;
|
||||
document.querySelector("#authPasswordConfirm").required = registering;
|
||||
document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password";
|
||||
document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录";
|
||||
document.querySelector("#authError").hidden = true;
|
||||
}
|
||||
|
||||
async function submitAuthForm(event) {
|
||||
event.preventDefault();
|
||||
const username = document.querySelector("#authUsername").value.trim();
|
||||
const password = document.querySelector("#authPassword").value;
|
||||
const errorElement = document.querySelector("#authError");
|
||||
if (state.authMode === "register" && password !== document.querySelector("#authPasswordConfirm").value) {
|
||||
errorElement.textContent = "两次输入的密码不一致。";
|
||||
errorElement.hidden = false;
|
||||
return;
|
||||
}
|
||||
const button = document.querySelector("#authSubmitButton");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", { username, password });
|
||||
document.querySelector("#authForm").reset();
|
||||
await applyAuthenticatedSession(session);
|
||||
} catch (error) {
|
||||
errorElement.textContent = error.message || "账号操作失败";
|
||||
errorElement.hidden = false;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAuthenticatedSession(session) {
|
||||
state.user = session.user;
|
||||
state.csrfToken = session.csrf_token || "";
|
||||
setText("accountName", session.user?.username || "账号");
|
||||
const isAdmin = session.user?.role === "admin";
|
||||
updateAccountIdentityBadges(session.user?.membership || {});
|
||||
document.querySelector("#settingsButton").hidden = !isAdmin;
|
||||
document.querySelector("#syncButton").hidden = !isAdmin;
|
||||
document.querySelector("#reasonForm").hidden = !isAdmin;
|
||||
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
||||
document.querySelector("#authGate").hidden = true;
|
||||
applyMembershipAccess();
|
||||
await startAuthenticatedApp();
|
||||
}
|
||||
|
||||
function showAuthGate(message = "") {
|
||||
state.user = null;
|
||||
state.csrfToken = "";
|
||||
const gate = document.querySelector("#authGate");
|
||||
gate.hidden = false;
|
||||
const errorElement = document.querySelector("#authError");
|
||||
errorElement.textContent = message;
|
||||
errorElement.hidden = !message;
|
||||
document.querySelector("#authUsername").focus();
|
||||
}
|
||||
|
||||
async function logoutAccount() {
|
||||
toggleAccountDropdown(false);
|
||||
try {
|
||||
await apiRequest("/api/auth/logout", "POST", {});
|
||||
} catch (error) {
|
||||
showToast(error.message || "退出失败");
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
|
||||
function hasMemberAccess() {
|
||||
return state.user?.role === "admin" || Boolean(state.user?.membership?.active);
|
||||
}
|
||||
|
||||
function updateAccountIdentityBadges(membership = {}) {
|
||||
const isAdmin = state.user?.role === "admin" || Boolean(membership.is_admin);
|
||||
const subscribed = Boolean(membership.subscribed);
|
||||
document.querySelector("#accountAdminBadge").hidden = !isAdmin;
|
||||
const vipBadge = document.querySelector("#accountVipBadge");
|
||||
vipBadge.hidden = false;
|
||||
vipBadge.classList.toggle("is-nonmember", !subscribed);
|
||||
setText("accountVipLabel", subscribed ? "会员" : "非会员");
|
||||
vipBadge.title = subscribed ? "查看会员状态" : "查看会员权益";
|
||||
}
|
||||
|
||||
function applyMembershipAccess() {
|
||||
const unlocked = hasMemberAccess();
|
||||
document.querySelectorAll(".member-feature-view").forEach((view) => {
|
||||
view.classList.toggle("member-locked", !unlocked);
|
||||
const gate = view.querySelector(".member-gate");
|
||||
if (gate) gate.hidden = unlocked;
|
||||
view.querySelectorAll("button, input, textarea, select").forEach((control) => {
|
||||
if (control.closest(".member-gate") || control.hasAttribute("data-member-navigation")) return;
|
||||
control.disabled = !unlocked;
|
||||
});
|
||||
});
|
||||
const assistantButton = document.querySelector("#assistantButton");
|
||||
assistantButton.classList.toggle("member-locked-control", !unlocked);
|
||||
assistantButton.title = unlocked ? "复盘助手" : "复盘助手(会员可用)";
|
||||
updateAssistantControls();
|
||||
}
|
||||
|
||||
|
||||
function selectAccountPanel(panel) {
|
||||
const selected = ["profile", "membership", "password"].includes(panel) ? panel : "profile";
|
||||
const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码" };
|
||||
setText("accountDialogTitle", titles[selected]);
|
||||
document.querySelectorAll("[data-account-panel-content]").forEach((section) => {
|
||||
section.hidden = section.dataset.accountPanelContent !== selected;
|
||||
});
|
||||
document.querySelector("#connectionStatus").hidden = selected !== "membership";
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function openSettings(panel = "profile") {
|
||||
selectAccountPanel(panel);
|
||||
toggleAccountDropdown(false);
|
||||
toggleHeaderCommandMenu(false);
|
||||
const status = document.querySelector("#connectionStatus");
|
||||
status.className = "connection-status";
|
||||
status.textContent = "正在读取账号状态";
|
||||
openModalDialog(elements.settingsDialog);
|
||||
try {
|
||||
const payload = await apiRequest("/api/account/status");
|
||||
const access = payload.llm_access || {};
|
||||
const membership = access.membership || {};
|
||||
if (state.user) {
|
||||
state.user.membership = membership;
|
||||
updateAccountIdentityBadges(membership);
|
||||
applyMembershipAccess();
|
||||
}
|
||||
status.textContent = membership.active ? "账户权益已同步" : "账户信息已同步";
|
||||
status.classList.toggle("connected", true);
|
||||
setText("membershipBadge", membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户");
|
||||
setText("membershipStateValue", membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通");
|
||||
setText("membershipRemainingValue", membership.subscribed && membership.expires_at
|
||||
? `${number(membership.remaining_days)} 天`
|
||||
: membership.is_admin || membership.subscribed ? "长期有效" : "--");
|
||||
setText("membershipDetail", membership.subscribed
|
||||
? `${membership.plan || "会员"}${membership.expires_at ? ` · 有效至 ${membershipDateDisplay(membership.expires_at, true)}` : " · 长期有效"}`
|
||||
: membership.is_admin
|
||||
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
|
||||
: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。");
|
||||
setText("membershipQuotaHint", `会员默认每日智能分析额度 ${number(access.daily_limit)} 次,由管理员统一设置。`);
|
||||
setText("membershipUsage", membership.active
|
||||
? `今日已用 ${number(access.used_today)} 次`
|
||||
: "今日智能分析:--");
|
||||
setText("membershipUsageSummary", membership.active ? `${number(access.used_today)} / ${number(access.daily_limit)}` : "--");
|
||||
setText("membershipRemainingUsage", membership.is_admin ? "不限" : membership.active ? `${number(access.remaining_calls)} 次` : "--");
|
||||
const birth = payload.birth_profile || {};
|
||||
if (birth.birth_datetime) {
|
||||
const [birthDate, birthTime] = String(birth.birth_datetime).split("T");
|
||||
document.querySelector("#accountBirthDate").value = birthDate || "";
|
||||
document.querySelector("#accountBirthTime").value = (birthTime || "").slice(0, 5);
|
||||
document.querySelector("#accountBirthGender").value = birth.gender || "unspecified";
|
||||
}
|
||||
setText("birthProfileStatus", payload.birth_profile_configured ? "已加密保存" : "尚未设置");
|
||||
document.querySelector("#deleteBirthProfileButton").disabled = !payload.birth_profile_configured;
|
||||
} catch (error) {
|
||||
status.hidden = false;
|
||||
status.textContent = "账户状态暂时无法同步";
|
||||
showToast(error.message || "账号信息加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function changeAccountPassword(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest("/api/account/password", "POST", {
|
||||
current_password: document.querySelector("#currentPassword").value,
|
||||
new_password: document.querySelector("#newPassword").value,
|
||||
confirm_password: document.querySelector("#confirmPassword").value,
|
||||
});
|
||||
form.reset();
|
||||
showToast("密码已更新");
|
||||
} catch (error) {
|
||||
showToast(error.message || "密码更新失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchAccount() {
|
||||
const button = document.querySelector("#switchAccountMenuButton");
|
||||
button.disabled = true;
|
||||
toggleAccountDropdown(false);
|
||||
try {
|
||||
await apiRequest("/api/auth/logout", "POST", {});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
showToast(error.message || "切换账号失败");
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function membershipDateDisplay(value) {
|
||||
if (!value) return "";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return String(value).slice(0, 10);
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function toggleAccountDropdown(force, returnFocus = false) {
|
||||
const menu = document.querySelector("#accountDropdown");
|
||||
const button = document.querySelector("#accountButton");
|
||||
if (!menu || !button) return;
|
||||
const open = typeof force === "boolean" ? force : menu.hidden;
|
||||
menu.hidden = !open;
|
||||
button.setAttribute("aria-expanded", String(open));
|
||||
document.querySelector(".account-menu-shell")?.classList.toggle("is-open", open);
|
||||
if (open) {
|
||||
setText("accountMenuName", state.user?.username || "当前账号");
|
||||
const membership = state.user?.membership || {};
|
||||
setText("accountMenuRole", state.user?.role === "admin" ? (membership.subscribed ? "管理员 · 会员" : "管理员") : membership.subscribed ? "会员用户" : "普通用户");
|
||||
} else if (returnFocus) {
|
||||
button.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleAccountMenuKeydown(event) {
|
||||
const menu = document.querySelector("#accountDropdown");
|
||||
if (!menu) return;
|
||||
if (menu.hidden) {
|
||||
if (document.activeElement?.id === "accountButton" && event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
toggleAccountDropdown(true);
|
||||
menu.querySelector('[role="menuitem"]')?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const items = [...menu.querySelectorAll('[role="menuitem"]:not(:disabled)')];
|
||||
if (!items.length) return;
|
||||
const current = items.indexOf(document.activeElement);
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const offset = event.key === "ArrowDown" ? 1 : -1;
|
||||
items[(current + offset + items.length) % items.length].focus();
|
||||
} else if (event.key === "Home" || event.key === "End") {
|
||||
event.preventDefault();
|
||||
items[event.key === "Home" ? 0 : items.length - 1].focus();
|
||||
}
|
||||
}
|
||||
|
||||
function bindSessionEvents() {
|
||||
document.querySelectorAll("[data-auth-mode]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectAuthMode(button.dataset.authMode));
|
||||
});
|
||||
document.querySelector("#authForm").addEventListener("submit", submitAuthForm);
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest(".account-menu-shell")) toggleAccountDropdown(false);
|
||||
});
|
||||
window.addEventListener("keydown", handleGlobalSearchShortcut);
|
||||
document.querySelectorAll("[data-open-account]").forEach((button) => {
|
||||
button.addEventListener("click", () => openSettings("membership"));
|
||||
});
|
||||
document.querySelector("#accountButton").addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
toggleAccountDropdown();
|
||||
});
|
||||
document.querySelector("#accountVipBadge").addEventListener("click", () => openSettings("membership"));
|
||||
document.querySelectorAll("[data-account-panel]").forEach((button) => {
|
||||
button.addEventListener("click", () => openSettings(button.dataset.accountPanel));
|
||||
});
|
||||
document.querySelector("#switchAccountMenuButton").addEventListener("click", switchAccount);
|
||||
document.querySelector("#logoutMenuButton").addEventListener("click", logoutAccount);
|
||||
document.querySelector("#closeSettingsDialog").addEventListener("click", () => elements.settingsDialog.close());
|
||||
document.querySelector("#accountBirthForm").addEventListener("submit", saveAccountBirthProfile);
|
||||
document.querySelector("#deleteBirthProfileButton").addEventListener("click", deleteAccountBirthProfile);
|
||||
document.querySelector("#passwordForm").addEventListener("submit", changeAccountPassword);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") toggleAccountDropdown(false, true);
|
||||
handleAccountMenuKeydown(event);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
function initializeAutoTableSorting() {
|
||||
markAutoSortableHeaders(document);
|
||||
document.addEventListener("click", (event) => {
|
||||
const header = event.target.closest?.("th[data-auto-sort]");
|
||||
if (!header || header.closest("#limitTable")) return;
|
||||
const table = header.closest("table");
|
||||
const body = table?.tBodies?.[0];
|
||||
if (!body || body.rows.length < 2) return;
|
||||
const direction = header.classList.contains("sort-asc") ? "desc" : "asc";
|
||||
table.querySelectorAll("th.sort-asc, th.sort-desc").forEach((item) => {
|
||||
item.classList.remove("sort-asc", "sort-desc", "sorted");
|
||||
item.removeAttribute("aria-sort");
|
||||
const arrow = item.querySelector(".arr");
|
||||
if (arrow) arrow.textContent = "↕";
|
||||
});
|
||||
header.classList.add(`sort-${direction}`, "sorted");
|
||||
header.setAttribute("aria-sort", direction === "asc" ? "ascending" : "descending");
|
||||
const activeArrow = header.querySelector(".arr");
|
||||
if (activeArrow) activeArrow.textContent = direction === "asc" ? "▲" : "▼";
|
||||
const columnIndex = header.cellIndex;
|
||||
const rows = [...body.rows].map((row, index) => ({ row, index }));
|
||||
rows.sort((left, right) => {
|
||||
const leftValue = autoSortValue(left.row.cells[columnIndex]);
|
||||
const rightValue = autoSortValue(right.row.cells[columnIndex]);
|
||||
let result;
|
||||
if (leftValue.kind === "number" && rightValue.kind === "number") result = leftValue.value - rightValue.value;
|
||||
else result = String(leftValue.value).localeCompare(String(rightValue.value), "zh-CN", { numeric: true, sensitivity: "base" });
|
||||
if (result === 0) result = left.index - right.index;
|
||||
return direction === "asc" ? result : -result;
|
||||
});
|
||||
rows.forEach(({ row }) => body.appendChild(row));
|
||||
const firstHeader = [...header.parentElement.cells][0]?.textContent.trim();
|
||||
if (["#", "排名"].includes(firstHeader)) {
|
||||
[...body.rows].forEach((row, index) => {
|
||||
if (row.cells[0]) row.cells[0].textContent = String(index + 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function markAutoSortableHeaders(root) {
|
||||
root.querySelectorAll?.(".data-table:not(#limitTable) thead th").forEach((header) => {
|
||||
if (header.closest("#brokenTable, #downTable, #yesterdayTable, #rotationTable")) return;
|
||||
if (number(header.colSpan) > 1) return;
|
||||
const label = header.textContent.trim();
|
||||
if (!label || ["#", "操作"].includes(label)) return;
|
||||
header.dataset.autoSort = "true";
|
||||
header.classList.add("sortable");
|
||||
if (!header.querySelector(".arr")) header.insertAdjacentHTML("beforeend", '<span class="arr">↕</span>');
|
||||
header.title = `${label}:点击排序`;
|
||||
});
|
||||
}
|
||||
|
||||
function autoSortValue(cell) {
|
||||
const text = String(cell?.dataset?.sortValue || cell?.textContent || "").trim();
|
||||
if (!text || text === "--" || text.includes("样本不足")) return { kind: "text", value: "\uffff" };
|
||||
const boardMatch = text.match(/(\d+)\s*板/);
|
||||
if (boardMatch) return { kind: "number", value: Number(boardMatch[1]) };
|
||||
const normalized = text.replaceAll(",", "").replace(/[+%]/g, "");
|
||||
const numericMatch = normalized.match(/^-?\d+(?:\.\d+)?/);
|
||||
if (numericMatch) {
|
||||
let value = Number(numericMatch[0]);
|
||||
if (text.includes("亿")) value *= 10000;
|
||||
return { kind: "number", value };
|
||||
}
|
||||
return { kind: "text", value: text };
|
||||
}
|
||||
|
||||
|
||||
function bindSharedTableEvents() {
|
||||
document.querySelectorAll("[data-table-search]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const query = input.value.trim().toLowerCase();
|
||||
const body = document.querySelector(`#${CSS.escape(input.dataset.tableSearch)}`);
|
||||
body?.querySelectorAll("tr").forEach((row) => {
|
||||
row.hidden = Boolean(query) && !row.textContent.toLowerCase().includes(query);
|
||||
});
|
||||
});
|
||||
});
|
||||
initializeAutoTableSorting();
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
const THEME_STORAGE_KEY = "xiaobaiTheme";
|
||||
let activeThemeTransition = null;
|
||||
let themeSwitchSequence = 0;
|
||||
|
||||
|
||||
function syncThemeControl() {
|
||||
const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "light";
|
||||
const button = document.querySelector("#themeToggle");
|
||||
if (!button) return;
|
||||
const dark = theme === "dark";
|
||||
const label = dark ? "切换到日间模式" : "切换到夜间模式";
|
||||
button.title = label;
|
||||
button.setAttribute("aria-label", label);
|
||||
button.setAttribute("aria-pressed", String(dark));
|
||||
button.querySelector("i")?.setAttribute("data-lucide", dark ? "sun" : "moon");
|
||||
}
|
||||
|
||||
function clearThemeTransitionEffects() {
|
||||
document.querySelectorAll(".row-enter, .row-pending, .view-entering").forEach((element) => {
|
||||
element.classList.remove("row-enter", "row-pending", "view-entering");
|
||||
element.style.removeProperty("--row-delay");
|
||||
});
|
||||
}
|
||||
|
||||
function redrawThemeSensitiveVisuals() {
|
||||
if (!elements.stockPreview.hidden && state.stockPreviewPayload) {
|
||||
selectStockPreviewChart(state.stockPreviewChart);
|
||||
}
|
||||
if (elements.stockDialog.open) {
|
||||
if (state.stockDetailChartMode === "intraday" && state.stockDetailIntraday?.points?.length) {
|
||||
drawIntradayCanvas(
|
||||
elements.priceChart,
|
||||
state.stockDetailIntraday.points,
|
||||
[],
|
||||
state.stockDetailIntraday.meta?.previous_close,
|
||||
);
|
||||
} else if (state.stockDetail?.prices) drawPriceChart(state.stockDetail.prices);
|
||||
}
|
||||
if (elements.entityDetailDialog.open) {
|
||||
if (state.entityDetailChartMode === "intraday" && state.entityDetailIntraday?.points?.length) {
|
||||
drawIntradayCanvas(
|
||||
elements.entityDetailChart,
|
||||
state.entityDetailIntraday.points,
|
||||
[],
|
||||
state.entityDetailIntraday.meta?.previous_close,
|
||||
);
|
||||
} else if (state.entityDetailPayload?.series) {
|
||||
drawEntityDetailChart(state.entityDetailPayload.series);
|
||||
}
|
||||
}
|
||||
if (state.activeView === "sentimentCycleView" && state.sentimentHistory) {
|
||||
drawSentimentTrendChart(state.sentimentHistory.rows || []);
|
||||
}
|
||||
if (state.activeView === "heavenView") {
|
||||
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
|
||||
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
|
||||
drawQiUseConnections(false);
|
||||
}
|
||||
if (state.heavenPanel === "heart") startHeartDust();
|
||||
}
|
||||
}
|
||||
|
||||
function commitTheme(normalized, persist) {
|
||||
document.documentElement.dataset.theme = normalized;
|
||||
document.documentElement.style.colorScheme = normalized;
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, normalized);
|
||||
} catch (_error) {
|
||||
// The selected theme still applies for the current page when storage is unavailable.
|
||||
}
|
||||
}
|
||||
syncThemeControl();
|
||||
refreshIcons();
|
||||
redrawThemeSensitiveVisuals();
|
||||
}
|
||||
|
||||
function applyTheme(theme, persist = true) {
|
||||
const normalized = theme === "dark" ? "dark" : "light";
|
||||
const root = document.documentElement;
|
||||
if (root.dataset.theme === normalized) {
|
||||
commitTheme(normalized, persist);
|
||||
return;
|
||||
}
|
||||
const sequence = ++themeSwitchSequence;
|
||||
activeThemeTransition?.skipTransition?.();
|
||||
clearThemeTransitionEffects();
|
||||
root.classList.add("theme-switching");
|
||||
|
||||
const update = () => commitTheme(normalized, persist);
|
||||
const finish = () => {
|
||||
if (sequence !== themeSwitchSequence) return;
|
||||
clearThemeTransitionEffects();
|
||||
root.classList.remove("theme-switching");
|
||||
activeThemeTransition = null;
|
||||
};
|
||||
const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
if (!reducedMotion && typeof document.startViewTransition === "function") {
|
||||
activeThemeTransition = document.startViewTransition(update);
|
||||
activeThemeTransition.finished.then(finish, finish);
|
||||
return;
|
||||
}
|
||||
update();
|
||||
requestAnimationFrame(() => requestAnimationFrame(finish));
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
|
||||
}
|
||||
|
||||
|
||||
function bindThemeEvents() {
|
||||
document.querySelector("#themeToggle").addEventListener("click", toggleTheme);
|
||||
window.addEventListener("resize", redrawThemeSensitiveVisuals);
|
||||
}
|
||||
Reference in New Issue
Block a user