refactor: streamline frontend language and shared UI utilities

This commit is contained in:
leefer
2026-07-22 23:45:57 +08:00
parent 18aed6a80f
commit 50dbb8a3ff
6 changed files with 155 additions and 118 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
智能选股模块包含 45 日全市场因子库、六阶段市场识别、六套内置策略、受控公式 DSL、自然语言策略编译、候选排名和滚动回测。首次使用需在页面点击“同步因子数据”。未配置 LLM 时使用本地策略模板;配置兼容 API 后自动切换为主模型编译,主模型失败时自动使用辅助模型,两者均支持独立连通性测试。
问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按老师和交易日期保存在浏览器本机;主模型不可用时自动切换辅助模型。
问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按账号、老师和交易日期保存在服务端;主模型不可用时自动切换辅助模型。
新增问师角色时,在 `游资skills` 下增加一个包含 `SKILL.md` 的独立目录即可。系统会从 Skill 的 frontmatter、一级标题、核心模型和引用语中自动生成角色信息,无需修改注册代码。
+14 -24
View File
@@ -459,7 +459,7 @@ class DashboardService:
profile = self._resolved_llm_profile()
source = str(profile.get("source") or "none")
if source == "none" or not self._profile_configured(profile.get("primary") or {}):
raise ValueError("请配置个人 LLM,或使用已开通会员的平台模型")
raise ValueError("智能功能尚未配置,请联系管理员")
if source == "platform":
limit = max(1, int(self._system_credentials.get("member_daily_limit") or 50))
if self._platform_usage_today() >= limit:
@@ -1126,18 +1126,14 @@ class DashboardService:
self.llm_fallback_model,
)
compiled["compiler"] = "llm_fallback"
notice = f"主模型调用失败,已自动切换辅助模型。{primary_error}" if primary_error else "已使用辅助模型编译"
notice = "智能策略生成服务已自动切换"
except LLMCompilerError as exc:
fallback_error = str(exc)
compiled = compile_local_strategy(prompt, regime)
notice = f"主模型和辅助模型均不可用,已使用本地模板。主模型:{primary_error or '未配置'};辅助模型:{fallback_error}"
notice = "智能策略生成暂不可用,已使用本地模板。"
if compiled is None:
compiled = compile_local_strategy(prompt, regime)
notice = (
f"主模型调用失败且未配置辅助模型,已使用本地模板:{primary_error}"
if primary_error
else "尚未配置 LLM,当前使用本地受控模板编译。"
)
notice = "智能策略生成暂不可用,已使用本地模板。"
compiled["formula"] = self.screener.validate_formula(compiled["formula"])
compiled["notice"] = notice
if source in {"personal", "platform"}:
@@ -1234,13 +1230,10 @@ class DashboardService:
self.record_llm_usage(
"mentor", source, self.llm_fallback_model, "failed"
)
raise ValueError(
f"主模型和辅助模型均不可用。主模型:{primary_error or '未配置'}"
f"辅助模型:{fallback_error}"
) from exc
raise ValueError("智能解读服务暂不可用,请稍后重试。") from exc
if result is None:
self.record_llm_usage("mentor", source, self.llm_primary_model, "failed")
raise ValueError(f"主模型不可用且未配置辅助模型:{primary_error}")
raise ValueError("智能解读服务暂不可用,请稍后重试。")
self.record_llm_usage(
"mentor",
source,
@@ -1262,7 +1255,7 @@ class DashboardService:
"compiler": compiler,
"requested_trade_date": trade_date,
"data_trade_date": context["data_trade_date"],
"notice": "主模型调用失败,已自动切换辅助模型" if compiler == "fallback" else "",
"notice": "智能解读已自动切换可用服务" if compiler == "fallback" else "",
}
def mentor_messages(self, mentor_id: str, trade_date: str) -> list[dict[str, Any]]:
@@ -2196,7 +2189,7 @@ class DashboardService:
**result,
"mode": mode,
"compiler": compiler,
"notice": "主模型调用失败,已自动切换辅助模型" if compiler == "fallback" else "",
"notice": "智能解读已自动切换可用服务" if compiler == "fallback" else "",
}
def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]:
@@ -2236,12 +2229,9 @@ class DashboardService:
self.record_llm_usage(
f"heaven_{mode}", source, self.llm_fallback_model, "failed"
)
raise ValueError(
f"主模型和辅助模型均不可用。主模型:{primary_error or '未配置'}"
f"辅助模型:{exc}"
) from exc
raise ValueError("智能解读服务暂不可用,请稍后重试。") from exc
self.record_llm_usage(f"heaven_{mode}", source, self.llm_primary_model, "failed")
raise ValueError(f"主模型不可用且未配置辅助模型:{primary_error}")
raise ValueError("智能解读服务暂不可用,请稍后重试。")
def _heaven_index_context(
self,
@@ -2612,7 +2602,7 @@ class DashboardService:
"schema_version": 3,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": f"游资接口不可用:{exc}",
"notice": "龙虎榜数据暂不可用,请稍后重试。",
},
"summary": {
"trader_count": 0,
@@ -2640,7 +2630,7 @@ class DashboardService:
"status": "unavailable",
"schema_version": 3,
"cached": False,
"notice": "公共行情尚未配置,暂无龙虎榜数据。",
"notice": "龙虎榜数据暂不可用,请联系管理员检查行情配置",
},
"summary": {
"trader_count": 0,
@@ -2997,7 +2987,7 @@ class DashboardService:
compact_date = normalize_date(resolved_date)
intraday_points: list[dict[str, Any]] = []
intraday_status = "unavailable"
intraday_notice = "未配置 Tushare Token,分时数据不可用。"
intraday_notice = "分时行情暂不可用。"
if self.configured:
cache_key = f"{code}:{compact_date}"
@@ -3023,7 +3013,7 @@ class DashboardService:
intraday_notice = "该交易日暂无分时数据。"
except TushareError as exc:
intraday_status = "unavailable"
intraday_notice = f"Tushare 分时接口不可用:{exc}"
intraday_notice = "分时行情暂不可用,请稍后重试。"
prices = list(detail.get("prices") or [])[-60:]
stock = dict(detail.get("stock") or {"code": code})
+35 -81
View File
@@ -1,3 +1,15 @@
const {
clamp,
displayCompactDate,
escapeHtml,
formatNumber,
formatTimestamp,
localDateString,
number,
parseLocalDate,
todayString,
} = window.XiaobaiUI;
const state = {
user: null,
csrfToken: "",
@@ -598,11 +610,7 @@ function applyDashboard(payload, background = false) {
document.querySelector("#qiObservationDate").value = selectedDate;
document.querySelector("#journalDate").value = selectedDate;
renderDashboard();
const source = payload.meta.source === "tushare" ? "Tushare" : "演示数据";
const cacheText = payload.meta.realtime
? "rt_k 实时行情"
: payload.meta.cached ? "SQLite 缓存" : "已写入 SQLite";
setStatus(`${source} · ${cacheText}`);
setStatus(`${dashboardSourceLabel(payload.meta)} · 数据已更新`);
if (!background) {
if (state.activeView === "dragonView") loadDragonTiger();
if (state.activeView === "screenerView") loadScreenerSetup();
@@ -619,10 +627,10 @@ function applyDashboard(payload, background = false) {
}
function dashboardSourceLabel(meta = {}) {
if (meta.source === "demo") return "演示数据";
if (meta.realtime && !["closed", "after_hours"].includes(String(meta.market_status || ""))) return "Tushare 实时行情";
if (meta.source === "tushare") return "Tushare 盘后行情";
return "本地行情";
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() {
@@ -1140,7 +1148,7 @@ async function loadDragonTiger(force = false) {
const requestedDate = elements.tradeDate.value;
if (
!force
&& ["success", "demo"].includes(state.dragonTiger?.meta?.status)
&& ["success", "empty", "partial", "unavailable"].includes(state.dragonTiger?.meta?.status)
&& (state.dragonTiger?.meta?.requested_date || state.dragonTiger?.meta?.trade_date) === requestedDate
) {
renderDragonTiger();
@@ -1159,7 +1167,7 @@ async function loadDragonTiger(force = false) {
? "当日暂无公开游资明细"
: payload.meta.status === "partial"
? "当日有龙虎榜,暂无命名游资明细"
: payload.meta.source === "tushare" ? "龙虎榜明细" : "演示数据";
: payload.meta.status === "unavailable" ? "龙虎榜数据暂不可用" : "龙虎榜明细";
setStatus(`${statusLabel} · 龙虎榜已加载`);
} catch (error) {
showToast(error.message || "龙虎榜加载失败");
@@ -1202,8 +1210,8 @@ function renderDragonTraderList() {
const container = document.querySelector("#dragonTraderList");
let emptyMessage = "没有符合当前条件的游资操作";
if (!Array.isArray(payload.traders)) emptyMessage = "后端服务尚未重启,当前接口仍是旧版龙虎榜结构";
else if (payload.meta?.status === "error") emptyMessage = "游资接口调用失败,请查看上方提示并检查 Tushare 积分权限";
if (!Array.isArray(payload.traders)) emptyMessage = "龙虎榜数据格式暂不可用,请稍后重试";
else if (["error", "unavailable"].includes(payload.meta?.status)) emptyMessage = "龙虎榜数据暂不可用,请稍后重试";
else if (payload.meta?.status === "empty") emptyMessage = "该交易日暂无游资每日明细";
else if (payload.meta?.status === "partial") emptyMessage = `当日有 ${number(payload.summary?.official_stock_count)} 只股票上榜,但暂无可识别的游资明细`;
if (!traders.some((item) => item.id === state.selectedDragonTraderId)) {
@@ -1600,8 +1608,7 @@ function renderScreenerSetup() {
? `${displayCompactDate(setup.factor_data.start_date)}${displayCompactDate(setup.factor_data.end_date)}`
: "尚未达到 21 个交易日");
setText("factorTaskStatus", setup.factor_data.ready ? `已就绪 · ${number(setup.factor_data.date_count)}` : "需要同步");
const compilerText = setup.llm.configured ? "模型编译就绪" : "本地编译就绪";
setText("compilerStatus", compilerText);
setText("compilerStatus", "策略生成已就绪");
setText(
"screenerRunStatus",
state.screenerResult ? `已有结果 · ${(state.screenerResult.candidates || []).length}` : "等待执行",
@@ -1721,10 +1728,7 @@ async function compileStrategy() {
document.querySelector("#strategyNameInput").value = strategy.name;
document.querySelector("#strategyDescriptionInput").value = strategy.description;
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
setText(
"compilerStatus",
strategy.compiler === "local" ? "本地编译完成" : "模型编译完成",
);
setText("compilerStatus", "策略生成完成");
if (strategy.notice) showToast(strategy.notice);
setStatus("选股策略已编译");
} catch (error) {
@@ -1923,11 +1927,10 @@ async function sendMentorQuestion(event) {
question,
history,
});
const modelRole = payload.compiler === "fallback" ? "辅助模型" : "主模型";
state.mentorMessages.push({
role: "assistant",
content: payload.answer,
meta: `${displayCompactDate(payload.data_trade_date)} · ${modelRole} ${payload.model} · ${number(payload.latency_ms)}ms`,
meta: `${displayCompactDate(payload.data_trade_date)} · 回答完成`,
});
if (payload.notice) showMentorNotice(payload.notice);
setStatus("问师回答完成");
@@ -2756,12 +2759,13 @@ function renderSectorPhaseOverrides(items) {
<div class="sector-phase-override-row">
<span class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</span>
<strong>${escapeHtml(item.name)}</strong>
${canManage ? `<button class="icon-button" type="button" data-sector-phase-delete="${escapeHtml(item.name)}" title="删除手动归类" aria-label="删除 ${escapeHtml(item.name)} 的手动归类">×</button>` : ""}
${canManage ? `<button class="icon-button" type="button" data-sector-phase-delete="${escapeHtml(item.name)}" title="删除手动归类" aria-label="删除 ${escapeHtml(item.name)} 的手动归类"><i data-lucide="trash-2"></i></button>` : ""}
</div>
`).join("") : '<p class="sector-phase-empty">暂无手动归类</p>';
container.querySelectorAll("[data-sector-phase-delete]").forEach((button) => {
button.addEventListener("click", () => deleteSectorPhaseOverride(button.dataset.sectorPhaseDelete));
});
refreshIcons();
}
async function saveSectorPhaseOverride(event) {
@@ -2933,10 +2937,9 @@ async function interpretHeaven(mode) {
if (mode === "trend" && state.heavenManualData) payload.manual_data = state.heavenManualData;
if (mode === "heart") payload.lines = state.heartLines;
const result = await apiRequest("/api/heaven/interpret", "POST", payload);
const modelRole = result.compiler === "fallback" ? "辅助模型" : "主模型";
state.heavenInterpretations[mode] = {
answer: result.answer,
meta: `${modelRole} ${result.model} · ${number(result.latency_ms)}ms`,
meta: mode === "trend" ? "观势已成" : mode === "fortune" ? "观气已成" : "解卦已成",
};
if (result.notice) showHeavenNotice(result.notice);
if (mode === "heart") {
@@ -3525,7 +3528,7 @@ function renderScreenerResult() {
setText(
"screenerDisclaimer",
meta.realtime
? `rt_k 实时截面 · 历史因子截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
);
document.querySelector("#screenerEmpty").hidden = candidates.length > 0;
@@ -4083,9 +4086,7 @@ function renderStockPreview(payload) {
setText("stockPreviewDate", payload.meta?.trade_date || elements.tradeDate.value);
document.querySelector("#stockPreviewLoading").hidden = true;
const intradayAvailable = (payload.intraday || []).length > 0;
const source = payload.meta?.realtime
? "Tushare 实时行情"
: payload.meta?.source === "tushare" ? "Tushare 日K" : "演示日K";
const source = payload.meta?.realtime ? "盘中行情" : "日 K 行情";
setText(
"stockPreviewSource",
intradayAvailable ? `${source} · 1分钟` : `${source} · 分时不可用`,
@@ -4478,7 +4479,7 @@ async function openStock(code, fallback = null) {
setText("detailName", stock.name || row.name);
setText("detailPrice", formatNumber(stock.price || row.price, 2));
setText("detailChange", `${signed(stock.change ?? row.change)}%`);
setText("chartSource", `${payload.meta.source === "tushare" ? "Tushare" : "演示"} · ${payload.prices.length} 个交易日`);
setText("chartSource", `日 K 行情 · ${payload.prices.length} 个交易日`);
renderMoneyflow(payload.moneyflow || {});
renderStockNotes(payload.notes || []);
updateWatchButton();
@@ -4705,8 +4706,8 @@ async function openSettings(panel = "profile") {
updateAccountIdentityBadges(membership);
applyMembershipAccess();
}
const sourceLabel = membership.active && access.platform_configured ? "平台会员模型" : "尚未开通会员算力";
status.textContent = `公共行情${payload.configured ? "已就绪" : "使用演示数据"} · ${sourceLabel}`;
const sourceLabel = membership.active && access.platform_configured ? "智能功能已就绪" : "智能功能尚未开通";
status.textContent = `公共行情${payload.configured ? "已就绪" : "等待管理员配置"} · ${sourceLabel}`;
status.classList.toggle("connected", Boolean(payload.configured));
setText("membershipBadge", membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户");
setText("membershipStateValue", membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通");
@@ -4717,11 +4718,11 @@ async function openSettings(panel = "profile") {
? `${membership.plan || "会员"}${membership.expires_at ? ` · 有效至 ${membershipDateDisplay(membership.expires_at, true)}` : " · 长期有效"}`
: membership.is_admin
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
: "开通会员后可使用智能选股、问师、问天及平台 LLM 算力。");
setText("membershipQuotaHint", `会员默认每日 LLM 用量 ${number(access.daily_limit)} 次,由管理员统一设置。`);
: "开通会员后可使用智能选股、问师、问天等智能功能。");
setText("membershipQuotaHint", `会员默认每日智能分析额度 ${number(access.daily_limit)} 次,由管理员统一设置。`);
setText("membershipUsage", membership.active
? `今日已用 ${number(access.used_today)}`
: "今日 LLM 用量--");
: "今日智能分析--");
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 || {};
@@ -5142,16 +5143,6 @@ function signed(value) {
return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`;
}
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 dashboardDataTimestamp(meta = {}) {
const tradeDate = displayCompactDate(meta.trade_date);
if (tradeDate === "--") return "--";
@@ -5174,12 +5165,6 @@ function formatMoneyMillion(value) {
return `${sign}${formatNumber(parsed * 100, 0)}`;
}
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)}`;
}
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
const options = { method, headers: {}, signal: requestOptions.signal };
if (method !== "GET" && state.csrfToken) {
@@ -5427,34 +5412,3 @@ function animateRows(container) {
function waitForMotion(duration) {
return new Promise((resolve) => setTimeout(resolve, motionEnabled() ? duration : 0));
}
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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;",
})[character]);
}
function todayString() {
return localDateString(new Date());
}
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 parseLocalDate(value) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
+13 -12
View File
@@ -1008,7 +1008,7 @@
<div class="dialog-header-actions">
<button id="stockHeavenButton" class="button stock-heaven-button" type="button"><i data-lucide="sparkles"></i><span>观势</span></button>
<button id="watchStockButton" class="button" type="button">加入自选</button>
<button id="closeStockDialog" class="icon-button" type="button" aria-label="关闭" title="关闭">×</button>
<button id="closeStockDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
</div>
<div class="detail-price-line">
@@ -1069,27 +1069,27 @@
<span class="dialog-eyebrow">当前账号</span>
<h2 id="accountDialogTitle">个人资料</h2>
</div>
<button id="closeSettingsDialog" class="icon-button" type="button" aria-label="关闭" title="关闭">×</button>
<button id="closeSettingsDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
<div id="connectionStatus" class="connection-status" aria-live="polite">正在读取账号状态</div>
<section class="settings-section membership-overview account-settings-panel" data-account-panel-content="membership" hidden>
<div class="settings-section-heading"><h3>会员状态</h3><span id="membershipBadge">普通用户</span></div>
<p id="membershipDetail" class="settings-lead">开通会员后可使用智能选股、问师、问天及平台 LLM 算力</p>
<p id="membershipQuotaHint" class="form-hint">会员默认每日 LLM 用量 -- 次,由管理员统一设置。</p>
<div id="membershipUsage" class="membership-usage">今日 LLM 用量 --</div>
<p id="membershipDetail" class="settings-lead">开通会员后可使用智能选股、问师、问天等智能功能</p>
<p id="membershipQuotaHint" class="form-hint">会员默认每日智能分析额度 -- 次,由管理员统一设置。</p>
<div id="membershipUsage" class="membership-usage">今日智能分析 --</div>
<div class="membership-status-grid">
<div><span>开通状态</span><strong id="membershipStateValue">未开通</strong></div>
<div><span>剩余时长</span><strong id="membershipRemainingValue">--</strong></div>
<div><span>今日 LLM 用量</span><strong id="membershipUsageSummary">--</strong></div>
<div><span>LLM 剩余用量</span><strong id="membershipRemainingUsage">--</strong></div>
<div><span>今日智能分析</span><strong id="membershipUsageSummary">--</strong></div>
<div><span>剩余智能分析</span><strong id="membershipRemainingUsage">--</strong></div>
</div>
<div class="membership-comparison" aria-label="会员权益对比">
<div class="membership-comparison-head"><span>功能</span><span>普通用户</span><span>会员用户</span></div>
<div><span>行情数据与股票预览</span><b>可用</b><b>可用</b></div>
<div><span>智能选股 / 问师 / 问天</span><b class="muted">不可用</b><b class="available">可用</b></div>
<div><span>平台 LLM 算力</span><b class="muted">不可用</b><b class="available">按额度使用</b></div>
<div><span>智能分析功能</span><b class="muted">不可用</b><b class="available">按额度使用</b></div>
</div>
<div class="membership-topup-row"><span><i data-lucide="battery-charging"></i>LLM 用量加油包</span><button id="membershipTopupButton" class="button" type="button" disabled>暂不可用</button></div>
<div class="membership-topup-row"><span><i data-lucide="battery-charging"></i>智能分析加油包</span><button id="membershipTopupButton" class="button" type="button" disabled>暂不可用</button></div>
</section>
<section class="settings-section account-birth-section account-settings-panel" data-account-panel-content="profile">
<div class="settings-section-heading"><h3>个人资料</h3><span id="birthProfileStatus">尚未设置</span></div>
@@ -1103,7 +1103,7 @@
<button class="button primary" type="submit">保存资料</button>
</div>
</form>
<p class="form-hint">原始信息加密保存且不在观气页回显;模型只接收排盘后的派生结果。</p>
<p class="form-hint">原始信息加密保存且不在观气页回显;智能解读只使用排盘后的派生结果。</p>
</section>
<section class="settings-section password-section account-settings-panel" data-account-panel-content="password" hidden>
<div class="settings-section-heading"><h3>修改密码</h3><span>仅当前账号</span></div>
@@ -1119,7 +1119,7 @@
<dialog id="adminDialog" class="settings-dialog admin-dialog">
<div class="dialog-header">
<div><span class="dialog-eyebrow">管理员</span><h2>系统配置</h2></div>
<button id="closeAdminDialog" class="icon-button" type="button" aria-label="关闭" title="关闭">×</button>
<button id="closeAdminDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
<div id="adminConnectionStatus" class="connection-status" aria-live="polite">正在读取系统状态</div>
<div class="admin-section-picker">
@@ -1161,7 +1161,7 @@
<div class="admin-panel" data-admin-panel="members" hidden>
<form id="membershipSettingsForm" class="settings-section membership-limit-form">
<div class="settings-section-heading"><h3>会员调用额度</h3><span>每日自动重置</span></div>
<label class="form-field compact-number-field"><span>会员每日调用 LLM 上限</span><input id="memberDailyLimit" type="number" min="1" max="1000" value="50"></label>
<label class="form-field compact-number-field"><span>会员每日智能分析上限</span><input id="memberDailyLimit" type="number" min="1" max="1000" value="50"></label>
<div class="dialog-actions"><button class="button primary" type="submit">保存调用额度</button></div>
</form>
<section class="settings-section">
@@ -1184,6 +1184,7 @@
<div id="toast" class="toast" role="status" hidden></div>
<script src="/vendor/lucide.min.js" defer></script>
<script src="/ui-core.js" defer></script>
<script src="/app.js" defer></script>
</body>
</html>
+69
View File
@@ -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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;",
})[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);
+23
View File
@@ -23,6 +23,7 @@ class FrontendContractTests(unittest.TestCase):
def setUpClass(cls) -> None:
cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
cls.ui_core = (STATIC_DIR / "ui-core.js").read_text(encoding="utf-8")
collector = IdCollector()
collector.feed(cls.html)
cls.ids = collector.ids
@@ -49,6 +50,28 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn('document.querySelector("#sectorPhaseManager").hidden = !isAdmin;', self.script)
self.assertIn('const canManage = state.user?.role === "admin";', self.script)
def test_shared_ui_core_loads_before_application(self):
self.assertLess(
self.html.index('<script src="/ui-core.js"'),
self.html.index('<script src="/app.js"'),
)
for function_name in (
"number", "clamp", "escapeHtml", "formatNumber", "formatTimestamp",
"displayCompactDate", "todayString", "localDateString", "parseLocalDate",
):
self.assertIn(f"function {function_name}(", self.ui_core)
def test_business_views_do_not_expose_engineering_source_labels(self):
prohibited = (
"Tushare 实时行情",
"Tushare 日K",
"SQLite 缓存",
"演示日K",
"rt_k 实时截面",
)
for label in prohibited:
self.assertNotIn(label, self.script)
if __name__ == "__main__":
unittest.main()