migration: preserve frontend shell pages and styles
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
(function exposeApiClient(global) {
|
||||
"use strict";
|
||||
|
||||
let csrfTokenSupplier = () => "";
|
||||
let unauthorizedHandler = () => {};
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message, status = 0, payload = null) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
function configure(options = {}) {
|
||||
if (typeof options.csrfToken === "function") csrfTokenSupplier = options.csrfToken;
|
||||
if (typeof options.onUnauthorized === "function") unauthorizedHandler = options.onUnauthorized;
|
||||
}
|
||||
|
||||
function requestOptions(method, body, signal) {
|
||||
const normalizedMethod = String(method || "GET").toUpperCase();
|
||||
const options = { method: normalizedMethod, headers: {}, signal };
|
||||
const csrfToken = csrfTokenSupplier();
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(normalizedMethod) && csrfToken) {
|
||||
options.headers["X-CSRF-Token"] = csrfToken;
|
||||
}
|
||||
if (body !== null && body !== undefined) {
|
||||
options.headers["Content-Type"] = "application/json";
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function parseJson(response) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnauthorized(response, url) {
|
||||
if (response.status === 401 && !String(url).startsWith("/api/auth/")) {
|
||||
unauthorizedHandler({ response, url });
|
||||
}
|
||||
}
|
||||
|
||||
async function request(url, method = "GET", body = null, options = {}) {
|
||||
const response = await fetch(url, requestOptions(method, body, options.signal));
|
||||
const payload = await parseJson(response);
|
||||
handleUnauthorized(response, url);
|
||||
if (!response.ok || payload.error) {
|
||||
throw new ApiError(payload.error || "请求失败", response.status, payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function streamNdjson(url, options = {}) {
|
||||
const response = await fetch(
|
||||
url,
|
||||
requestOptions(options.method || "POST", options.body, options.signal),
|
||||
);
|
||||
if (!response.ok) {
|
||||
const payload = await parseJson(response);
|
||||
handleUnauthorized(response, url);
|
||||
throw new ApiError(
|
||||
payload.error || options.errorMessage || "流式请求暂不可用",
|
||||
response.status,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
if (!response.body) throw new ApiError("当前浏览器不支持流式回答", response.status);
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const consume = (line) => {
|
||||
if (!line.trim()) return;
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch (_error) {
|
||||
throw new ApiError("流式响应格式错误", response.status);
|
||||
}
|
||||
if (event.type === "error") {
|
||||
throw new ApiError(event.error || options.errorMessage || "流式请求失败", response.status, event);
|
||||
}
|
||||
options.onEvent?.(event);
|
||||
};
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
lines.forEach(consume);
|
||||
if (done) break;
|
||||
}
|
||||
if (buffer.trim()) consume(buffer);
|
||||
}
|
||||
|
||||
global.XiaobaiAPI = Object.freeze({ ApiError, configure, request, streamNdjson });
|
||||
})(window);
|
||||
@@ -0,0 +1,54 @@
|
||||
(function exposeSharedComponents(global) {
|
||||
"use strict";
|
||||
|
||||
const ui = global.XiaobaiUI;
|
||||
if (!ui) throw new Error("XiaobaiUI must load before shared components");
|
||||
|
||||
function resolveElement(target, root = document) {
|
||||
if (target instanceof Element) return target;
|
||||
if (typeof target !== "string" || !target) return null;
|
||||
return target.startsWith("#") ? root.querySelector(target) : root.getElementById?.(target);
|
||||
}
|
||||
|
||||
function classNames(...values) {
|
||||
return values.flatMap((value) => String(value || "").split(/\s+/)).filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function emptyStateHtml(message, options = {}) {
|
||||
const classes = classNames("empty-state", options.className);
|
||||
const attributes = options.role ? ` role="${ui.escapeHtml(options.role)}"` : "";
|
||||
return `<div class="${ui.escapeHtml(classes)}"${attributes}>${ui.escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function renderEmptyState(target, message, options = {}) {
|
||||
const element = resolveElement(target, options.root);
|
||||
if (!element) return false;
|
||||
element.innerHTML = emptyStateHtml(message, options);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setText(target, value, options = {}) {
|
||||
const element = resolveElement(target, options.root);
|
||||
if (!element) return false;
|
||||
element.textContent = value == null ? "" : String(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderCollection(target, items, renderItem, options = {}) {
|
||||
const element = resolveElement(target, options.root);
|
||||
if (!element) return 0;
|
||||
const rows = Array.isArray(items) ? items : [];
|
||||
element.innerHTML = rows.length
|
||||
? rows.map((item, index) => renderItem(item, index)).join("")
|
||||
: emptyStateHtml(options.emptyMessage || "", options.emptyOptions);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
global.XiaobaiComponents = Object.freeze({
|
||||
classNames,
|
||||
emptyStateHtml,
|
||||
renderCollection,
|
||||
renderEmptyState,
|
||||
setText,
|
||||
});
|
||||
})(window);
|
||||
@@ -0,0 +1,149 @@
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:8905-9051 */
|
||||
function exportStocks() {
|
||||
exportRows("涨停池", getVisibleStocks(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["连板", "streak"], ["涨幅%", "change"],
|
||||
["价格", "price"], ["所属板块", "sector"], ["涨停原因", "reason"], ["首封", "first_time"],
|
||||
["最后封板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"],
|
||||
["成交额亿", "amount_billion"], ["封单额万", "seal_amount_million"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportBroken() {
|
||||
exportRows("炸板池", getVisibleBrokenRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["现价涨幅%", "change"], ["距涨停%", "limitGap"],
|
||||
["价格", "price"], ["所属板块", "sector"], ["首次触板", "first_time"], ["开板次数", "open_times"],
|
||||
["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportDown() {
|
||||
exportRows("跌停板", getVisibleDownRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["跌幅%", "change"], ["价格", "price"],
|
||||
["所属板块", "sector"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportYesterday() {
|
||||
exportRows("昨日涨停", getVisibleYesterdayRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"],
|
||||
["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"],
|
||||
["所属板块", "sector"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportLadder() {
|
||||
const rows = (state.dashboard?.ladders || []).flatMap((group) => (group.stocks || []).map((stock) => ({
|
||||
level: group.label || group.level,
|
||||
...stock,
|
||||
})));
|
||||
exportRows("市场天梯", rows, [
|
||||
["梯队", "level"], ["股票代码", "code"], ["股票名称", "name"], ["所属板块", "sector"],
|
||||
["封板时间", "first_time"], ["开板次数", "open_times"], ["封单额万", "seal_amount_million"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportRotation() {
|
||||
const sectorMap = new Map((state.dashboard?.sectors || []).map((sector) => [sector.name, sector]));
|
||||
const rows = (state.dashboard?.sector_rotation || []).map((row) => ({
|
||||
...row,
|
||||
average_change: sectorMap.get(row.name)?.change ?? 0,
|
||||
}));
|
||||
exportRows("板块轮动", rows, [
|
||||
["排名", "rank"], ["板块", "name"], ["趋势", "trend"], ["今日涨停", "count"],
|
||||
["昨日涨停", "previous_count"], ["变化", "delta"], ["强度", "strength"],
|
||||
["最高板", "max_streak"], ["平均涨幅%", "average_change"],
|
||||
["领涨股", "leader"], ["涨停股成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportSentimentHistory() {
|
||||
const rows = state.sentimentHistory?.rows || [];
|
||||
if (!rows.length) {
|
||||
showToast("暂无可导出的情绪周期数据");
|
||||
return;
|
||||
}
|
||||
const exportRowsData = rows.map((row) => ({
|
||||
...row,
|
||||
breadth_score: row.components?.breadth?.score,
|
||||
limit_ecology_score: row.components?.limit_ecology?.score,
|
||||
profit_effect_score: row.components?.profit_effect?.score,
|
||||
ladder_structure_score: row.components?.ladder_structure?.score,
|
||||
liquidity_score: row.components?.liquidity?.score,
|
||||
}));
|
||||
exportRows("情绪周期", exportRowsData, [
|
||||
["交易日", "trade_date"], ["情绪温度", "score"], ["周期阶段", "phase"], ["方向", "direction"],
|
||||
["涨停", "limit_up_count"], ["首板", "first_board_count"], ["二板", "second_board_count"],
|
||||
["三板以上", "three_plus_count"], ["连板高度", "max_height"], ["炸板", "broken_count"],
|
||||
["跌停", "limit_down_count"], ["昨日涨停", "previous_limit_count"],
|
||||
["昨日涨停红盘", "previous_positive_count"], ["昨日涨停红盘率%", "previous_positive_rate"],
|
||||
["市场宽度", "breadth_score"], ["涨停生态", "limit_ecology_score"],
|
||||
["赚钱效应", "profit_effect_score"], ["连板结构", "ladder_structure_score"],
|
||||
["成交活跃度", "liquidity_score"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportDragonTiger() {
|
||||
const rows = (state.dragonTiger?.traders || []).flatMap((trader) => (
|
||||
(trader.operations || []).map((operation) => ({
|
||||
trader_name: trader.name,
|
||||
identity_type: dragonIdentityLabel(trader.identity_type),
|
||||
...operation,
|
||||
}))
|
||||
));
|
||||
exportRows("游资龙虎榜", rows, [
|
||||
["游资或席位", "trader_name"], ["身份", "identity_type"], ["股票代码", "code"],
|
||||
["股票名称", "name"], ["方向", "direction"], ["涨幅%", "change"],
|
||||
["买入百万元", "buy_million"], ["卖出百万元", "sell_million"], ["净额百万元", "net_buy_million"],
|
||||
["关联席位", "seat_name"], ["上榜原因", "reason"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportHotMoneyProfiles() {
|
||||
const rows = state.hotMoneyProfiles?.profiles || [];
|
||||
if (!rows.length) {
|
||||
showToast("暂无可导出的游资档案");
|
||||
return;
|
||||
}
|
||||
downloadCsv(
|
||||
`游资档案-${todayString()}.csv`,
|
||||
["游资名称", "简介", "关联营业部", "席位数量"],
|
||||
rows.map((profile) => [
|
||||
profile.name,
|
||||
profile.description,
|
||||
(profile.organizations || []).join(";"),
|
||||
number(profile.organization_count),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function commonReviewColumns() {
|
||||
return [["股票代码", "code"], ["股票名称", "name"], ["状态", "status"], ["涨跌幅%", "change"],
|
||||
["价格", "price"], ["所属板块", "sector"], ["原因", "reason"], ["首次触板", "first_time"],
|
||||
["最后触板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"]];
|
||||
}
|
||||
|
||||
function exportRows(label, rows, columns) {
|
||||
const headers = columns.map(([header]) => header);
|
||||
const data = rows.map((row) => columns.map(([, key]) => row[key] ?? ""));
|
||||
downloadCsv(`${label}-${state.dashboard.meta.trade_date}.csv`, headers, data);
|
||||
}
|
||||
|
||||
function downloadCsv(filename, headers, rows) {
|
||||
const lines = [headers, ...rows].map((row) => row.map(csvCell).join(","));
|
||||
const blob = new Blob(["\ufeff", lines.join("\r\n")], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`已导出 ${rows.length} 条数据`);
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
let text = String(value ?? "");
|
||||
if (/^[=+\-@]/.test(text)) text = `'${text}`;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:8905-9051 */
|
||||
@@ -0,0 +1,194 @@
|
||||
(function exposeApplicationShell(global) {
|
||||
"use strict";
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "xiaobai-sidebar-collapsed";
|
||||
|
||||
function create(options) {
|
||||
const state = options.state;
|
||||
const registry = options.pages;
|
||||
let initialized = false;
|
||||
|
||||
function toggleHeaderCommandMenu(force) {
|
||||
const menu = document.querySelector("#headerCommandGroup");
|
||||
const button = document.querySelector("#headerMenuButton");
|
||||
if (!menu || !button) return;
|
||||
const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open");
|
||||
menu.classList.toggle("is-open", open);
|
||||
button.setAttribute("aria-expanded", String(open));
|
||||
}
|
||||
|
||||
function updateSidebarControl() {
|
||||
const button = document.querySelector("#sidebarCollapseButton");
|
||||
if (!button) return;
|
||||
const automaticallyCollapsed = global.innerWidth <= 1023 && global.innerWidth > 720;
|
||||
const collapsed = document.body.classList.contains("sidebar-collapsed") || automaticallyCollapsed;
|
||||
button.setAttribute("aria-expanded", String(!collapsed));
|
||||
button.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏");
|
||||
button.title = collapsed ? "展开侧栏" : "收起侧栏";
|
||||
const label = button.querySelector("span");
|
||||
if (label) label.textContent = collapsed ? "展开侧栏" : "收起侧栏";
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
const collapsed = document.body.classList.toggle("sidebar-collapsed");
|
||||
try {
|
||||
global.localStorage.setItem(SIDEBAR_STORAGE_KEY, collapsed ? "1" : "0");
|
||||
} catch (_error) {
|
||||
// The shell remains usable when storage is unavailable.
|
||||
}
|
||||
updateSidebarControl();
|
||||
}
|
||||
|
||||
function syncNavigation(viewId) {
|
||||
const page = registry.get(viewId);
|
||||
const navigationId = page?.navigation_alias || viewId;
|
||||
const marketView = page?.group === "market";
|
||||
document.body.dataset.activeView = viewId;
|
||||
document.querySelectorAll(".module-tab").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.view === navigationId);
|
||||
button.classList.toggle(
|
||||
"mobile-active",
|
||||
global.innerWidth <= 720
|
||||
&& marketView
|
||||
&& button.dataset.view === "limitPool"
|
||||
&& viewId !== "limitPool",
|
||||
);
|
||||
});
|
||||
const selector = document.querySelector("#mobileMarketSelector");
|
||||
const select = document.querySelector("#mobileMarketViewSelect");
|
||||
if (selector) selector.hidden = !marketView;
|
||||
if (select && marketView) select.value = viewId;
|
||||
toggleHeaderCommandMenu(false);
|
||||
options.onNavigationSync?.(viewId);
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
const status = document.querySelector("#statusText");
|
||||
if (status) status.textContent = text;
|
||||
}
|
||||
|
||||
function setPageStatus(viewId, tradeDate = "") {
|
||||
const page = registry.get(viewId);
|
||||
const label = page?.title || "小白复盘";
|
||||
setStatus(tradeDate && tradeDate !== "--" ? `${label} · 数据日期 ${tradeDate}` : `${label} · 等待数据`);
|
||||
}
|
||||
|
||||
function openModalDialog(dialog) {
|
||||
if (!(dialog instanceof HTMLDialogElement)) return;
|
||||
document.querySelectorAll("dialog[open]").forEach((openDialog) => {
|
||||
if (openDialog !== dialog) openDialog.close();
|
||||
});
|
||||
if (!dialog.open) dialog.showModal();
|
||||
}
|
||||
|
||||
function mount(viewId, mountOptions = {}) {
|
||||
const page = registry.get(viewId);
|
||||
const view = document.getElementById(viewId);
|
||||
if (!page || !view?.classList.contains("workspace-view")) return false;
|
||||
const previousView = state.activeView;
|
||||
options.onBeforeMount?.(viewId, previousView);
|
||||
state.activeView = viewId;
|
||||
document.querySelectorAll(".workspace-view").forEach((candidate) => {
|
||||
const active = candidate.id === viewId;
|
||||
candidate.classList.toggle("active-view", active);
|
||||
candidate.classList.remove("view-entering");
|
||||
if (active && options.motionEnabled?.()) {
|
||||
void candidate.offsetWidth;
|
||||
candidate.classList.add("view-entering");
|
||||
candidate.addEventListener(
|
||||
"animationend",
|
||||
() => candidate.classList.remove("view-entering"),
|
||||
{ once: true },
|
||||
);
|
||||
const body = candidate.querySelector("tbody");
|
||||
if (body) options.animateRows?.(body);
|
||||
}
|
||||
});
|
||||
syncNavigation(viewId);
|
||||
setPageStatus(viewId, options.tradeDate?.() || "");
|
||||
if (mountOptions.updateUrl !== false) {
|
||||
const url = new URL(global.location.href);
|
||||
url.searchParams.set("view", viewId);
|
||||
url.hash = "";
|
||||
global.history.replaceState(null, "", url);
|
||||
}
|
||||
global.scrollTo({ top: 0, behavior: "auto" });
|
||||
options.onAfterMount?.(viewId, previousView);
|
||||
return true;
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
let collapsed = false;
|
||||
try {
|
||||
collapsed = global.localStorage.getItem(SIDEBAR_STORAGE_KEY) === "1";
|
||||
} catch (_error) {
|
||||
collapsed = false;
|
||||
}
|
||||
document.body.classList.toggle("sidebar-collapsed", collapsed);
|
||||
updateSidebarControl();
|
||||
syncNavigation(state.activeView);
|
||||
document.querySelectorAll(".module-tab").forEach((button) => {
|
||||
button.addEventListener("click", () => options.onNavigate?.(button.dataset.view));
|
||||
});
|
||||
document.querySelectorAll("[data-open-view]").forEach((button) => {
|
||||
button.addEventListener("click", () => options.onNavigate?.(button.dataset.openView));
|
||||
});
|
||||
document.querySelector("#mobileMarketViewSelect")?.addEventListener("change", (event) => {
|
||||
options.onNavigate?.(event.target.value);
|
||||
});
|
||||
document.querySelector("#sidebarCollapseButton")?.addEventListener("click", toggleSidebar);
|
||||
document.querySelector("#headerMenuButton")?.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
toggleHeaderCommandMenu();
|
||||
});
|
||||
document.querySelector("#headerCommandGroup")?.addEventListener("click", (event) => {
|
||||
if (event.target.closest("button") && !event.target.closest(".account-menu-shell")) {
|
||||
toggleHeaderCommandMenu(false);
|
||||
}
|
||||
});
|
||||
document.querySelector("#overviewToggle")?.addEventListener("click", (event) => {
|
||||
const overview = document.querySelector(".overview-strip");
|
||||
if (!overview) return;
|
||||
const expanded = overview.dataset.overviewExpanded !== "true";
|
||||
overview.dataset.overviewExpanded = String(expanded);
|
||||
event.currentTarget.setAttribute("aria-expanded", String(expanded));
|
||||
event.currentTarget.title = expanded ? "收起市场详情" : "展开市场详情";
|
||||
const label = event.currentTarget.querySelector("span");
|
||||
if (label) label.textContent = expanded ? "收起详情" : "展开详情";
|
||||
event.currentTarget.querySelector("i")?.setAttribute(
|
||||
"data-lucide",
|
||||
expanded ? "chevron-up" : "chevron-down",
|
||||
);
|
||||
options.refreshIcons?.();
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false);
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") toggleHeaderCommandMenu(false);
|
||||
});
|
||||
global.addEventListener("resize", () => {
|
||||
if (global.innerWidth > 720) toggleHeaderCommandMenu(false);
|
||||
updateSidebarControl();
|
||||
syncNavigation(state.activeView);
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
initialize,
|
||||
mount,
|
||||
openModalDialog,
|
||||
page: (viewId) => registry.get(viewId),
|
||||
setPageStatus,
|
||||
setStatus,
|
||||
syncNavigation,
|
||||
toggleHeaderCommandMenu,
|
||||
toggleSidebar,
|
||||
updateSidebarControl,
|
||||
});
|
||||
}
|
||||
|
||||
global.XiaobaiShell = Object.freeze({ create });
|
||||
})(window);
|
||||
@@ -0,0 +1,44 @@
|
||||
(function exposeStateStore(global) {
|
||||
"use strict";
|
||||
|
||||
function create(domains) {
|
||||
const owners = new Map();
|
||||
const stores = {};
|
||||
Object.entries(domains).forEach(([domain, values]) => {
|
||||
stores[domain] = { ...values };
|
||||
Object.keys(values).forEach((key) => {
|
||||
if (owners.has(key)) throw new Error(`Duplicate state field: ${key}`);
|
||||
owners.set(key, domain);
|
||||
});
|
||||
});
|
||||
|
||||
const proxy = new Proxy({}, {
|
||||
get(_target, key) {
|
||||
if (key === "domain") return (name) => stores[name];
|
||||
if (key === "domains") return Object.freeze({ ...stores });
|
||||
if (typeof key !== "string" || !owners.has(key)) return undefined;
|
||||
return stores[owners.get(key)][key];
|
||||
},
|
||||
set(_target, key, value) {
|
||||
if (typeof key !== "string" || !owners.has(key)) {
|
||||
throw new Error(`Unregistered application state field: ${String(key)}`);
|
||||
}
|
||||
stores[owners.get(key)][key] = value;
|
||||
return true;
|
||||
},
|
||||
has(_target, key) {
|
||||
return key === "domain" || key === "domains" || owners.has(key);
|
||||
},
|
||||
ownKeys() {
|
||||
return [...owners.keys()];
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, key) {
|
||||
if (!owners.has(key)) return undefined;
|
||||
return { enumerable: true, configurable: true };
|
||||
},
|
||||
});
|
||||
return proxy;
|
||||
}
|
||||
|
||||
global.XiaobaiState = Object.freeze({ create });
|
||||
})(window);
|
||||
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* Canonical frontend tokens.
|
||||
*
|
||||
* Ownership flows in one direction:
|
||||
* primitive values -> semantic meaning -> component contracts.
|
||||
* Historical variable names remain aliases so page CSS can migrate without
|
||||
* changing the rendered interface.
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* Primitive tokens */
|
||||
--color-white: #ffffff;
|
||||
--color-gray-25: #fcfcfd;
|
||||
--color-gray-50: #f8f9fb;
|
||||
--color-gray-100: #f2f4f7;
|
||||
--color-gray-200: #e5e8ee;
|
||||
--color-gray-300: #d4d9e2;
|
||||
--color-gray-500: #697386;
|
||||
--color-gray-700: #344054;
|
||||
--color-gray-900: #172033;
|
||||
--color-shell-canvas: #f1f4f6;
|
||||
--color-page-canvas: #f4f5f7;
|
||||
--color-surface-muted: #f6f8fa;
|
||||
--color-surface-subtle: #f8fafc;
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-strong: #d1d5db;
|
||||
--color-text-primary: #1f2937;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
--color-action-base: #1769c2;
|
||||
--color-action-base-hover: #10569f;
|
||||
--color-action-base-soft: #eaf2fb;
|
||||
--color-action: #2563eb;
|
||||
--color-action-hover: #1d4ed8;
|
||||
--color-action-soft: #eff4ff;
|
||||
--color-action-line: #c7d8fb;
|
||||
--color-market-up-base: #d33f49;
|
||||
--color-market-up-base-soft: #fff0f1;
|
||||
--color-market-up: #e04536;
|
||||
--color-market-up-soft: #fdecea;
|
||||
--color-market-down-base: #07805b;
|
||||
--color-market-down-base-soft: #eaf7f2;
|
||||
--color-market-down: #16a34a;
|
||||
--color-market-down-soft: #e9f7ee;
|
||||
--color-warning-base: #aa6800;
|
||||
--color-warning-base-soft: #fff6e5;
|
||||
--color-warning: #b45309;
|
||||
--color-warning-soft: #fdf3e3;
|
||||
|
||||
--size-radius-sm: 5px;
|
||||
--size-radius-md: 7px;
|
||||
--size-radius-lg: 10px;
|
||||
--size-control: 32px;
|
||||
--size-sidebar: 200px;
|
||||
--size-topbar: 46px;
|
||||
--size-summary: 34px;
|
||||
--size-statusbar: 30px;
|
||||
--size-page-pad-y: 14px;
|
||||
--size-page-pad-x: 16px;
|
||||
--size-card-gap: 12px;
|
||||
|
||||
--elevation-card: 0 1px 2px rgba(16, 24, 40, .05);
|
||||
--elevation-soft: 0 1px 2px rgba(22, 34, 46, .04), 0 5px 18px rgba(22, 34, 46, .035);
|
||||
--elevation-raised: 0 4px 14px rgba(16, 24, 40, .06);
|
||||
--elevation-float: 0 14px 38px rgba(16, 24, 40, .14);
|
||||
--motion-instant: 100ms;
|
||||
--motion-fast: 140ms;
|
||||
--motion-medium: 200ms;
|
||||
--motion-deliberate: 260ms;
|
||||
--motion-slow: 560ms;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
/* Semantic tokens */
|
||||
--canvas: var(--color-shell-canvas);
|
||||
--surface: var(--color-white);
|
||||
--surface-muted: var(--color-surface-muted);
|
||||
--surface-subtle: var(--color-surface-subtle);
|
||||
--surface-canvas: var(--color-page-canvas);
|
||||
--surface-raised: var(--color-white);
|
||||
--surface-selected: #eef4ff;
|
||||
--border: var(--color-border);
|
||||
--border-strong: var(--color-border-strong);
|
||||
--text-primary: var(--color-text-primary);
|
||||
--text-secondary: var(--color-text-secondary);
|
||||
--text-tertiary: var(--color-text-tertiary);
|
||||
--action: var(--color-action-base);
|
||||
--action-hover: var(--color-action-base-hover);
|
||||
--action-soft: var(--color-action-base-soft);
|
||||
--market-up: var(--color-market-up-base);
|
||||
--market-up-soft: var(--color-market-up-base-soft);
|
||||
--market-down: var(--color-market-down-base);
|
||||
--market-down-soft: var(--color-market-down-base-soft);
|
||||
--warning-color: var(--color-warning-base);
|
||||
--warning-soft: var(--color-warning-base-soft);
|
||||
--primary: var(--color-action);
|
||||
--primary-hover: var(--color-action-hover);
|
||||
--danger: var(--color-market-up);
|
||||
--success: var(--color-market-down);
|
||||
--warning: var(--color-warning);
|
||||
|
||||
/* Component tokens */
|
||||
--card-bg: var(--surface-raised);
|
||||
--card-border: var(--border);
|
||||
--card-radius: var(--size-radius-lg);
|
||||
--card-shadow: var(--elevation-card);
|
||||
--control-height: var(--size-control);
|
||||
--page-gap: var(--size-card-gap);
|
||||
--radius-sm: var(--size-radius-sm);
|
||||
--radius-md: var(--size-radius-md);
|
||||
--radius-lg: var(--size-radius-lg);
|
||||
--shadow-xs: var(--elevation-card);
|
||||
--shadow-sm: var(--elevation-raised);
|
||||
--shadow-float: var(--elevation-float);
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 220ms;
|
||||
|
||||
--sidebar-width: var(--size-sidebar);
|
||||
--topbar-height: var(--size-topbar);
|
||||
--summary-height: var(--size-summary);
|
||||
--statusbar-height: var(--size-statusbar);
|
||||
--page-pad-y: var(--size-page-pad-y);
|
||||
--page-pad-x: var(--size-page-pad-x);
|
||||
--card-gap: var(--size-card-gap);
|
||||
--workspace-height: calc(100vh - var(--topbar-height) - var(--statusbar-height));
|
||||
--content-height: calc(var(--workspace-height) - var(--summary-height));
|
||||
--table-wide: 1180px;
|
||||
--table-medium: 930px;
|
||||
--table-compact: 720px;
|
||||
--col-rank: 44px;
|
||||
--col-date: 94px;
|
||||
--col-stock: 160px;
|
||||
--col-number: 96px;
|
||||
--col-action: 96px;
|
||||
--col-text: 220px;
|
||||
--right-rail-wide: 372px;
|
||||
--pool-table-max-height: calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap));
|
||||
--sentiment-history-max-height: 510px;
|
||||
--sentiment-history-min-height: 220px;
|
||||
--primary-share: 1.45fr;
|
||||
--secondary-share: .75fr;
|
||||
--mobile-nav-height: 58px;
|
||||
--mobile-header-height: 50px;
|
||||
--mobile-tab-height: 54px;
|
||||
--mobile-shell-pad: 8px;
|
||||
--mobile-page-pad: 10px;
|
||||
--mobile-min-width: 320px;
|
||||
--space-4: 4px;
|
||||
--font-aux: 10.5px;
|
||||
|
||||
--dragon-profile-list-width: 340px;
|
||||
--dragon-profile-detail-min-height: 460px;
|
||||
--dragon-profile-list-max-height: 320px;
|
||||
--dragon-profile-row-min-height: 64px;
|
||||
--dragon-profile-row-avatar-size: 36px;
|
||||
--dragon-profile-avatar-size: 72px;
|
||||
--dragon-profile-control-height: 33px;
|
||||
--dragon-profile-gap: 12px;
|
||||
--dragon-profile-panel-padding: 16px;
|
||||
--dragon-profile-row-padding: 10px 12px;
|
||||
--dragon-profile-title-font: 18px;
|
||||
--dragon-profile-name-font: 13px;
|
||||
--dragon-profile-body-font: 12px;
|
||||
--dragon-profile-meta-font: 11px;
|
||||
--dragon-profile-transition: 160ms ease;
|
||||
--dragon-profile-border-width: 1px;
|
||||
--dragon-profile-focus-width: 2px;
|
||||
--dragon-profile-focus-offset: -2px;
|
||||
--dragon-profile-radius-inset: 2px;
|
||||
--dragon-profile-body-line-height: 1.75;
|
||||
--dragon-profile-icon-stroke: 1;
|
||||
--dragon-profile-weight-strong: 750;
|
||||
--dragon-profile-weight-semibold: 600;
|
||||
|
||||
--chart-background: #fbfcfd;
|
||||
--chart-grid: #e2e8ec;
|
||||
--chart-axis: #6c7983;
|
||||
--chart-zero: #aeb7c1;
|
||||
--chart-line: #1d65c1;
|
||||
--chart-average: #b7791f;
|
||||
--chart-up: #c93f45;
|
||||
--chart-down: #087a55;
|
||||
--chart-up-volume: rgba(201, 63, 69, .58);
|
||||
--chart-down-volume: rgba(8, 122, 85, .58);
|
||||
--chart-area: rgba(37, 99, 235, .07);
|
||||
--chart-alert-area: rgba(224, 69, 54, .05);
|
||||
--chart-moving-average: #d1d5db;
|
||||
--chart-repair: #f59e0b;
|
||||
--chart-ma-10: #a76500;
|
||||
--chart-ma-20: #626c78;
|
||||
|
||||
/* Compatibility aliases. Do not add new usage of these names. */
|
||||
--xb-gray-25: var(--color-gray-25);
|
||||
--xb-gray-50: var(--color-gray-50);
|
||||
--xb-gray-100: var(--color-gray-100);
|
||||
--xb-gray-200: var(--color-gray-200);
|
||||
--xb-gray-300: var(--color-gray-300);
|
||||
--xb-gray-500: var(--color-gray-500);
|
||||
--xb-gray-700: var(--color-gray-700);
|
||||
--xb-gray-900: var(--color-gray-900);
|
||||
--xb-blue-50: #eef4ff;
|
||||
--xb-blue-100: #dce8ff;
|
||||
--xb-blue-500: var(--color-action);
|
||||
--xb-blue-600: var(--color-action-hover);
|
||||
--xb-red-50: #fff1f0;
|
||||
--xb-red-500: var(--color-market-up);
|
||||
--xb-green-50: #ecf9f1;
|
||||
--xb-green-500: var(--color-market-down);
|
||||
--xb-amber-50: #fff7e8;
|
||||
--xb-amber-500: var(--color-warning);
|
||||
|
||||
--bg: var(--surface-canvas);
|
||||
--card: var(--surface-raised);
|
||||
--ink: var(--text-primary);
|
||||
--sub: var(--text-secondary);
|
||||
--faint: var(--text-tertiary);
|
||||
--line: var(--border);
|
||||
--line-soft: #eef0f3;
|
||||
--line-strong: var(--border-strong);
|
||||
--text: var(--text-primary);
|
||||
--text-muted: var(--text-secondary);
|
||||
--blue: var(--primary);
|
||||
--blue-d: var(--primary-hover);
|
||||
--blue-dark: var(--action-hover);
|
||||
--blue-soft: var(--color-action-soft);
|
||||
--blue-line: var(--color-action-line);
|
||||
--up: var(--danger);
|
||||
--up-soft: var(--color-market-up-soft);
|
||||
--down: var(--success);
|
||||
--down-soft: var(--color-market-down-soft);
|
||||
--coral: var(--market-up);
|
||||
--coral-soft: var(--market-up-soft);
|
||||
--green: var(--market-down);
|
||||
--green-soft: var(--market-down-soft);
|
||||
--amber: var(--color-warning);
|
||||
--amber-soft: var(--color-warning-soft);
|
||||
--radius: var(--size-radius-lg);
|
||||
--shadow: var(--elevation-card);
|
||||
--shadow-soft: var(--elevation-soft);
|
||||
|
||||
--r2-blue: var(--primary);
|
||||
--r2-blue-dark: var(--primary-hover);
|
||||
--r2-blue-soft: var(--color-action-soft);
|
||||
--r2-blue-line: var(--color-action-line);
|
||||
--r2-up: var(--danger);
|
||||
--r2-up-soft: var(--color-market-up-soft);
|
||||
--r2-down: var(--success);
|
||||
--r2-down-soft: var(--color-market-down-soft);
|
||||
--r2-amber: var(--color-warning);
|
||||
--r2-amber-soft: var(--color-warning-soft);
|
||||
--r2-ink: var(--text-primary);
|
||||
--r2-sub: var(--text-secondary);
|
||||
--r2-faint: var(--text-tertiary);
|
||||
--r2-line: var(--border);
|
||||
--r2-line-soft: #eef0f3;
|
||||
--r2-bg: var(--surface-canvas);
|
||||
--r2-card: var(--surface-raised);
|
||||
--r2-radius: var(--size-radius-lg);
|
||||
--r2-shadow: var(--elevation-card);
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", "PingFang SC", "Microsoft YaHei UI", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--canvas: #121416;
|
||||
--surface: #1b1e21;
|
||||
--surface-muted: #202428;
|
||||
--surface-subtle: #24282d;
|
||||
--surface-canvas: var(--canvas);
|
||||
--surface-raised: var(--surface);
|
||||
--surface-selected: #23364a;
|
||||
--border: #343a40;
|
||||
--border-strong: #474f57;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #adb5bd;
|
||||
--text-tertiary: #7f8993;
|
||||
--action: #6ca8e8;
|
||||
--action-hover: #8bbcf0;
|
||||
--action-soft: #23364a;
|
||||
--market-up: #f06d73;
|
||||
--market-up-soft: #40262a;
|
||||
--market-down: #43bc8a;
|
||||
--market-down-soft: #1d382f;
|
||||
--warning-color: #e2ad58;
|
||||
--warning-soft: #3d3220;
|
||||
--primary: var(--action);
|
||||
--primary-hover: var(--action-hover);
|
||||
--danger: var(--market-up);
|
||||
--success: var(--market-down);
|
||||
--warning: var(--warning-color);
|
||||
--card-bg: var(--surface);
|
||||
--card-border: var(--border);
|
||||
|
||||
--xb-gray-25: var(--surface);
|
||||
--xb-gray-50: var(--surface-muted);
|
||||
--xb-gray-100: var(--canvas);
|
||||
--xb-gray-200: var(--border);
|
||||
--xb-gray-300: var(--border-strong);
|
||||
--xb-gray-500: var(--text-secondary);
|
||||
--xb-gray-700: #cbd1d7;
|
||||
--xb-gray-900: var(--text-primary);
|
||||
--xb-blue-50: var(--action-soft);
|
||||
--xb-blue-100: #294866;
|
||||
--xb-blue-500: var(--action);
|
||||
--xb-blue-600: var(--action-hover);
|
||||
--xb-red-50: var(--market-up-soft);
|
||||
--xb-red-500: var(--market-up);
|
||||
--xb-green-50: var(--market-down-soft);
|
||||
--xb-green-500: var(--market-down);
|
||||
--xb-amber-50: var(--warning-soft);
|
||||
--xb-amber-500: var(--warning-color);
|
||||
|
||||
--bg: var(--canvas);
|
||||
--card: var(--surface);
|
||||
--ink: var(--text-primary);
|
||||
--sub: var(--text-secondary);
|
||||
--faint: var(--text-tertiary);
|
||||
--line: var(--border);
|
||||
--line-soft: #2a2f34;
|
||||
--line-strong: var(--border-strong);
|
||||
--text: var(--text-primary);
|
||||
--text-muted: var(--text-secondary);
|
||||
--blue: var(--action);
|
||||
--blue-d: var(--action-hover);
|
||||
--blue-dark: var(--action-hover);
|
||||
--blue-soft: var(--action-soft);
|
||||
--blue-line: #42698e;
|
||||
--up: var(--market-up);
|
||||
--up-soft: var(--market-up-soft);
|
||||
--down: var(--market-down);
|
||||
--down-soft: var(--market-down-soft);
|
||||
--coral: var(--market-up);
|
||||
--coral-soft: var(--market-up-soft);
|
||||
--green: var(--market-down);
|
||||
--green-soft: var(--market-down-soft);
|
||||
--amber: var(--warning-color);
|
||||
--amber-soft: var(--warning-soft);
|
||||
--r2-blue: var(--action);
|
||||
--r2-blue-dark: var(--action-hover);
|
||||
--r2-blue-soft: var(--action-soft);
|
||||
--r2-blue-line: #42698e;
|
||||
--r2-up: var(--market-up);
|
||||
--r2-up-soft: var(--market-up-soft);
|
||||
--r2-down: var(--market-down);
|
||||
--r2-down-soft: var(--market-down-soft);
|
||||
--r2-amber: var(--warning-color);
|
||||
--r2-amber-soft: var(--warning-soft);
|
||||
--r2-ink: var(--text-primary);
|
||||
--r2-sub: var(--text-secondary);
|
||||
--r2-faint: var(--text-tertiary);
|
||||
--r2-line: var(--border);
|
||||
--r2-line-soft: #2a2f34;
|
||||
--r2-bg: var(--canvas);
|
||||
--r2-card: var(--surface);
|
||||
--r2-shadow: var(--shadow-soft);
|
||||
|
||||
--dialog-ink: var(--text-primary);
|
||||
--dialog-line: var(--border);
|
||||
--heaven-paper: #191a18;
|
||||
--heaven-paper-soft: #20211e;
|
||||
--heaven-ink: #e5e0d4;
|
||||
--heaven-muted: #aaa497;
|
||||
--heaven-rule: #3d3a34;
|
||||
--chart-background: #181b1e;
|
||||
--chart-grid: #30363c;
|
||||
--chart-axis: #a3adb6;
|
||||
--chart-zero: #68737d;
|
||||
--chart-line: #6ca8e8;
|
||||
--chart-average: #e2ad58;
|
||||
--chart-up: #f06d73;
|
||||
--chart-down: #43bc8a;
|
||||
--chart-up-volume: rgba(240, 109, 115, .52);
|
||||
--chart-down-volume: rgba(67, 188, 138, .52);
|
||||
--chart-area: rgba(108, 168, 232, .12);
|
||||
--chart-alert-area: rgba(240, 109, 115, .09);
|
||||
--chart-moving-average: #69737d;
|
||||
--chart-repair: #e2ad58;
|
||||
--chart-ma-10: #d39a45;
|
||||
--chart-ma-20: #9aa5af;
|
||||
--on-action: #101418;
|
||||
--warning-line: #6d5a38;
|
||||
--warning-line-strong: #66502d;
|
||||
--control-shadow: 0 1px 3px rgba(0, 0, 0, .3);
|
||||
--dialog-backdrop: rgba(0, 0, 0, .62);
|
||||
--ladder-level-1: #2d2426;
|
||||
--ladder-level-2: #2b2822;
|
||||
--ladder-level-3: #252a2d;
|
||||
--ladder-level-4: #20282b;
|
||||
--ladder-level-5: #202428;
|
||||
--heat-strong-bg: #304f7a;
|
||||
--heat-strong-ink: #f2f6fb;
|
||||
--heat-warm-bg: #2b405f;
|
||||
--heat-warm-ink: #dfeaf7;
|
||||
--heat-mild-bg: #293440;
|
||||
--heat-mild-ink: #c7d2dc;
|
||||
--heaven-field-bg: #23241f;
|
||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16);
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, .46);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
(function exposeUiCore(global) {
|
||||
"use strict";
|
||||
|
||||
function number(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, number(value)));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[character]);
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 0) {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(number(value));
|
||||
}
|
||||
|
||||
function formatTimestamp(value) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "--";
|
||||
return parsed.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function displayCompactDate(value) {
|
||||
const text = String(value || "").replaceAll("-", "");
|
||||
if (text.length !== 8) return value || "--";
|
||||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function localDateString(value) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function todayString() {
|
||||
return localDateString(new Date());
|
||||
}
|
||||
|
||||
function parseLocalDate(value) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
global.XiaobaiUI = Object.freeze({
|
||||
clamp,
|
||||
displayCompactDate,
|
||||
escapeHtml,
|
||||
formatNumber,
|
||||
formatTimestamp,
|
||||
localDateString,
|
||||
number,
|
||||
parseLocalDate,
|
||||
todayString,
|
||||
});
|
||||
})(window);
|
||||
Reference in New Issue
Block a user