refactor: streamline frontend language and shared UI utilities
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
|
||||
智能选股模块包含 45 日全市场因子库、六阶段市场识别、六套内置策略、受控公式 DSL、自然语言策略编译、候选排名和滚动回测。首次使用需在页面点击“同步因子数据”。未配置 LLM 时使用本地策略模板;配置兼容 API 后自动切换为主模型编译,主模型失败时自动使用辅助模型,两者均支持独立连通性测试。
|
||||
|
||||
问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按老师和交易日期保存在浏览器本机;主模型不可用时自动切换辅助模型。
|
||||
问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按账号、老师和交易日期保存在服务端;主模型不可用时自动切换辅助模型。
|
||||
|
||||
新增问师角色时,在 `游资skills` 下增加一个包含 `SKILL.md` 的独立目录即可。系统会从 Skill 的 frontmatter、一级标题、核心模型和引用语中自动生成角色信息,无需修改注册代码。
|
||||
|
||||
|
||||
@@ -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
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user