${escapeHtml(item.name || `模型 ${index + 1}`)}${item.configured ? "已保存密钥" : "待配置"}
未测试
`).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) => `
${escapeHtml(user.username)}${escapeHtml(identityLabels)}${escapeHtml(expiry)}
今日调用 ${number(user.used_today)}
`;
}).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 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);
}
/* PRESERVATION-SOURCE-END app.js:8427-8904 */
/* PRESERVATION-SOURCE-BEGIN app.js:9052-9283 */
function trendClass(trend) {
return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "trend-flat" }[trend] || "trend-flat";
}
function changeClass(value) {
return number(value) > 0 ? "up" : number(value) < 0 ? "down" : "";
}
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 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 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 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 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 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 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));
}
/* PRESERVATION-SOURCE-END app.js:9052-9283 */