feat: complete heaven readings and screener publication
This commit is contained in:
+133
-67
@@ -1341,13 +1341,13 @@ body[data-active-view="heavenView"] .workspace-view {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.personal-day-master > span {
|
||||
.personal-day-master-label {
|
||||
color: var(--text-secondary);
|
||||
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.personal-day-master > strong {
|
||||
.personal-day-master-identity {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
@@ -1355,26 +1355,58 @@ body[data-active-view="heavenView"] .workspace-view {
|
||||
gap: 8px;
|
||||
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.personal-day-master-mark {
|
||||
width: 34px;
|
||||
|
||||
height: 34px;
|
||||
|
||||
display: grid;
|
||||
|
||||
place-items: center;
|
||||
|
||||
flex: 0 0 34px;
|
||||
|
||||
border: 1px solid currentColor;
|
||||
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
background: var(--wt-soft-gold);
|
||||
}
|
||||
|
||||
.personal-day-master-mark svg {
|
||||
width: 18px;
|
||||
|
||||
height: 18px;
|
||||
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.personal-day-master-copy {
|
||||
min-width: 0px;
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
align-items: flex-start;
|
||||
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.personal-day-master-copy > strong {
|
||||
white-space: nowrap;
|
||||
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.personal-day-master .phase-symbol {
|
||||
width: 28px;
|
||||
|
||||
height: 28px;
|
||||
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.personal-day-master > small {
|
||||
.personal-day-master-copy > small {
|
||||
color: var(--text-secondary);
|
||||
|
||||
font-size: 10px;
|
||||
|
||||
display: block;
|
||||
|
||||
margin-top: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#heavenFortunePanel .fortune-metric:nth-child(3n) {
|
||||
@@ -2420,7 +2452,7 @@ body[data-active-view="heavenView"] .workspace-view {
|
||||
border-color: var(--heaven-rule);
|
||||
}
|
||||
|
||||
#heavenFortunePanel .personal-day-master > strong {
|
||||
#heavenFortunePanel .personal-day-master-copy > strong {
|
||||
font-family: var(--heaven-serif);
|
||||
}
|
||||
|
||||
@@ -2940,35 +2972,13 @@ body[data-active-view="heavenView"] .workspace-view {
|
||||
:where(#heavenView) #heavenFortunePanel .personal-day-master {
|
||||
border-color: var(--heaven-rule);
|
||||
|
||||
display: grid;
|
||||
display: flex;
|
||||
|
||||
justify-items: center;
|
||||
}
|
||||
flex-direction: column;
|
||||
|
||||
.personal-day-master-character {
|
||||
margin-top: 13px;
|
||||
align-items: flex-start;
|
||||
|
||||
font-family: var(--heaven-serif);
|
||||
|
||||
font-weight: 500;
|
||||
|
||||
line-height: 1;
|
||||
|
||||
font-size: 58px !important;
|
||||
}
|
||||
|
||||
.personal-day-master-element {
|
||||
margin-top: 12px;
|
||||
|
||||
font-family: var(--heaven-serif);
|
||||
|
||||
font-size: 15px;
|
||||
|
||||
font-weight: 500;
|
||||
|
||||
letter-spacing: 0.32em;
|
||||
|
||||
text-indent: 0.32em;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.phase-text-wood {
|
||||
@@ -5028,6 +5038,78 @@ body[data-active-view="heavenView"] .workspace-view {
|
||||
color: rgb(113, 96, 71);
|
||||
}
|
||||
|
||||
#heavenHeartPanel .heart-question-composer {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--card-gap);
|
||||
}
|
||||
|
||||
#heavenHeartPanel #heartIntro .heart-stage-inner {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
#heavenHeartPanel .heart-question-presets {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
#heavenHeartPanel .heart-question-presets button {
|
||||
min-width: calc(var(--control-height) * 2);
|
||||
height: var(--control-height);
|
||||
border: 1px solid var(--ritual-rule);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ritual-muted);
|
||||
font-family: var(--heaven-serif);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--duration-fast), background var(--duration-fast), color var(--duration-fast);
|
||||
}
|
||||
|
||||
#heavenHeartPanel .heart-question-presets button:hover:not(:disabled),
|
||||
#heavenHeartPanel .heart-question-presets button[aria-pressed="true"] {
|
||||
border-color: var(--heaven-cinnabar);
|
||||
background: var(--heaven-cinnabar-soft);
|
||||
color: var(--heaven-cinnabar);
|
||||
}
|
||||
|
||||
#heavenHeartPanel .heart-question-presets button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
#heavenHeartPanel #heartQuestionInput {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: calc(var(--control-height) * 2);
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--wt-control-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--card-gap);
|
||||
background: var(--wt-control-bg);
|
||||
color: var(--wt-text);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
outline: none;
|
||||
transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
|
||||
}
|
||||
|
||||
#heavenHeartPanel #heartQuestionInput::placeholder {
|
||||
color: var(--wt-faint);
|
||||
}
|
||||
|
||||
#heavenHeartPanel #heartQuestionInput:focus {
|
||||
border-color: var(--heaven-cinnabar);
|
||||
box-shadow: 0 0 0 2px var(--heaven-cinnabar-soft);
|
||||
}
|
||||
|
||||
#heavenHeartPanel #heartQuestionInput:disabled {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
#heavenHeartPanel .button {
|
||||
background: transparent;
|
||||
|
||||
@@ -9372,7 +9454,7 @@ body[data-active-view="heavenView"] .workspace-view {
|
||||
}
|
||||
|
||||
#heavenView #heavenFortunePanel .personal-primary-grid {
|
||||
grid-template-columns: 76px minmax(0px, 1fr);
|
||||
grid-template-columns: 112px minmax(0px, 1fr);
|
||||
|
||||
gap: 12px;
|
||||
|
||||
@@ -9404,7 +9486,7 @@ body[data-active-view="heavenView"] .workspace-view {
|
||||
#heavenView #heavenFortunePanel .personal-day-master {
|
||||
min-height: 118px;
|
||||
|
||||
padding: 0px;
|
||||
padding: 0px 12px 0px 0px;
|
||||
|
||||
border-right: 1px solid var(--wt-line);
|
||||
}
|
||||
@@ -10183,7 +10265,7 @@ body[data-active-view="heavenView"] .workspace-view {
|
||||
.personal-primary-grid {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: 76px minmax(0px, 1fr);
|
||||
grid-template-columns: 112px minmax(0px, 1fr);
|
||||
|
||||
gap: 12px;
|
||||
|
||||
@@ -10191,42 +10273,26 @@ body[data-active-view="heavenView"] .workspace-view {
|
||||
}
|
||||
|
||||
.personal-day-master {
|
||||
display: grid;
|
||||
display: flex;
|
||||
|
||||
place-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
align-content: center;
|
||||
align-items: flex-start;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
min-height: 118px;
|
||||
|
||||
border-right: 1px solid var(--wt-line);
|
||||
}
|
||||
|
||||
.personal-day-master > span {
|
||||
.personal-day-master-label {
|
||||
color: var(--wt-faint);
|
||||
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.personal-day-master-character {
|
||||
margin-top: 7px;
|
||||
|
||||
font-family: var(--heaven-serif);
|
||||
|
||||
font-size: 35px;
|
||||
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.personal-day-master-element {
|
||||
margin-top: 5px;
|
||||
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.personal-day-master small {
|
||||
margin-top: 5px;
|
||||
|
||||
.personal-day-master-copy small {
|
||||
color: var(--wt-faint);
|
||||
|
||||
font-size: 9px;
|
||||
|
||||
@@ -171,14 +171,21 @@
|
||||
<div id="heartIntro" class="heart-stage active-heart-stage">
|
||||
<div class="heart-stage-inner">
|
||||
<span class="heart-stage-index heart-rise" data-heart-delay="0">观心 · 一</span>
|
||||
<h3 class="heart-rise wt-serif" data-heart-delay="420">把所问之事留在心里</h3>
|
||||
<h3 class="heart-rise wt-serif" data-heart-delay="420">把所问之事安放于心</h3>
|
||||
<div class="heart-guidance heart-rise" data-heart-delay="900">
|
||||
<p>只问一事,不必说出来。</p>
|
||||
<p>心里默念它发生的对象与时间。</p>
|
||||
<p>只问一事,在起卦前定下所问。</p>
|
||||
<p>不求一个喜欢的答案,只看自己真正担心什么。</p>
|
||||
</div>
|
||||
<div class="heart-question-composer heart-rise" data-heart-delay="1250">
|
||||
<div class="heart-question-presets" aria-label="快捷问题">
|
||||
<button type="button" data-heart-question-preset="trade" aria-pressed="false">交易</button>
|
||||
<button type="button" data-heart-question-preset="mind" aria-pressed="false">心境</button>
|
||||
<button type="button" data-heart-question-preset="unthemed" aria-pressed="false">无题</button>
|
||||
</div>
|
||||
<textarea id="heartQuestionInput" rows="2" maxlength="300" placeholder="写下这次想问的一件事" aria-label="观心问题"></textarea>
|
||||
</div>
|
||||
<blockquote class="heart-motto heart-rise wt-serif" data-heart-delay="1500">遇事不决可问春风,春风不语即随本心</blockquote>
|
||||
<button id="startBreathingButton" class="button primary heart-rise" data-heart-delay="2200" type="button">开始静心</button>
|
||||
<button id="startBreathingButton" class="button primary heart-rise" data-heart-delay="2200" type="button" disabled>开始静心</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ const HEART_BREATH_PREPARE_MS = 1_000;
|
||||
const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS;
|
||||
const HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5;
|
||||
const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS;
|
||||
const HEART_QUESTION_PRESETS = {
|
||||
trade: "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?",
|
||||
mind: "此刻影响我交易判断的情绪、执念或盲点是什么?",
|
||||
unthemed: "不设具体问题,只观此刻一念。",
|
||||
};
|
||||
|
||||
let qiFieldAnimationFrame = 0;
|
||||
let qiFieldSoloElement = "";
|
||||
@@ -477,30 +482,41 @@ async function playFortunePerformance(field, token) {
|
||||
const climateText = climateTone?.textContent || "";
|
||||
if (climateTone) climateTone.textContent = "";
|
||||
renderQiFieldCanvas(field.balance || [], { intro: true });
|
||||
if (!await heavenPerformanceDelay(900, token)) return false;
|
||||
panel.classList.add("performance-climate-ready");
|
||||
if (!await heavenPerformanceDelay(720, token)) return false;
|
||||
if (!await typeHeavenText(climateTone, climateText, token, 58)) return false;
|
||||
|
||||
const balanceRows = [...panel.querySelectorAll(".phase-balance-row")];
|
||||
for (const row of balanceRows) {
|
||||
row.classList.add("is-ready");
|
||||
const percent = number(row.dataset.phasePercent);
|
||||
if (!await countHeavenNumber(row.querySelector(":scope > b"), percent, token, 520, "%")) return false;
|
||||
if (!await heavenPerformanceDelay(90, token)) return false;
|
||||
}
|
||||
const layers = [...panel.querySelectorAll(".qi-framework-layer")];
|
||||
for (const layer of layers) {
|
||||
layer.classList.add("is-ready");
|
||||
const climateSequence = async () => {
|
||||
if (!await heavenPerformanceDelay(900, token)) return false;
|
||||
panel.classList.add("performance-climate-ready");
|
||||
if (!await heavenPerformanceDelay(720, token)) return false;
|
||||
return typeHeavenText(climateTone, climateText, token, 58);
|
||||
};
|
||||
const qiSequence = async () => {
|
||||
if (!await heavenPerformanceDelay(900, token)) return false;
|
||||
const balanceRows = [...panel.querySelectorAll(".phase-balance-row")];
|
||||
for (const row of balanceRows) {
|
||||
row.classList.add("is-ready");
|
||||
const percent = number(row.dataset.phasePercent);
|
||||
if (!await countHeavenNumber(row.querySelector(":scope > b"), percent, token, 520, "%")) return false;
|
||||
if (!await heavenPerformanceDelay(90, token)) return false;
|
||||
}
|
||||
const layers = [...panel.querySelectorAll(".qi-framework-layer")];
|
||||
for (const layer of layers) {
|
||||
layer.classList.add("is-ready");
|
||||
if (!await heavenPerformanceDelay(250, token)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const personalSequence = async () => {
|
||||
if (!await heavenPerformanceDelay(900, token)) return false;
|
||||
panel.querySelectorAll(".human-field-grid > div").forEach((item, index) => {
|
||||
setTimeout(() => {
|
||||
if (token === heavenPerformanceToken) item.classList.add("is-ready");
|
||||
}, motionEnabled() ? index * 150 : 0);
|
||||
});
|
||||
if (!await heavenPerformanceDelay(250, token)) return false;
|
||||
}
|
||||
panel.querySelectorAll(".human-field-grid > div").forEach((item, index) => {
|
||||
setTimeout(() => {
|
||||
if (token === heavenPerformanceToken) item.classList.add("is-ready");
|
||||
}, motionEnabled() ? index * 150 : 0);
|
||||
});
|
||||
if (!await heavenPerformanceDelay(820, token)) return false;
|
||||
panel.querySelector(".personal-fortune-panel")?.classList.add("is-ready");
|
||||
panel.querySelector(".personal-fortune-panel")?.classList.add("is-ready");
|
||||
return true;
|
||||
};
|
||||
const sequences = await Promise.all([climateSequence(), qiSequence(), personalSequence()]);
|
||||
if (sequences.some((completed) => !completed) || token !== heavenPerformanceToken) return false;
|
||||
panel.classList.add("performance-use-ready");
|
||||
drawQiUseConnections(true);
|
||||
panel.classList.remove("heaven-performance-pending", "heaven-performance-running");
|
||||
@@ -1047,19 +1063,26 @@ function renderPersonalFortune() {
|
||||
const tenGods = personal.ten_god_tendency || { favorable: [], caution: [] };
|
||||
const elementTendency = personal.balance_tendency || { favorable: [], caution: [] };
|
||||
const preferenceTags = (items) => (items || []).map((item) => `<em>${escapeHtml(item)}</em>`).join("") || "--";
|
||||
const dayMasterElement = personal.day_master?.element || "";
|
||||
const dayMasterPhase = phaseClass(dayMasterElement);
|
||||
container.innerHTML = `
|
||||
<div class="personal-primary-grid">
|
||||
<div class="personal-day-master">
|
||||
<span>日主</span>
|
||||
<strong class="personal-day-master-character phase-text-${phaseClass(personal.day_master?.element)}">${escapeHtml(personal.day_master?.stem || "--")}</strong>
|
||||
<b class="personal-day-master-element phase-text-${phaseClass(personal.day_master?.element)}">${escapeHtml(personal.day_master?.element || "--")}</b>
|
||||
<small>${escapeHtml(personal.day_master?.strength || "")}</small>
|
||||
<span class="personal-day-master-label">本命日主</span>
|
||||
<div class="personal-day-master-identity">
|
||||
<span class="personal-day-master-mark phase-text-${dayMasterPhase}" aria-hidden="true"><i data-lucide="${phaseIcon(dayMasterElement)}"></i></span>
|
||||
<span class="personal-day-master-copy">
|
||||
<strong class="phase-text-${dayMasterPhase}">${escapeHtml(personal.day_master?.stem || "--")}${escapeHtml(dayMasterElement)}</strong>
|
||||
<small>日主${personal.day_master?.strength ? ` · ${escapeHtml(personal.day_master.strength)}` : ""}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="personal-preferences">
|
||||
<section><span>十神喜恶</span><div class="personal-preference-line"><strong>偏宜</strong><p>${preferenceTags(tenGods.favorable)}</p></div><div class="personal-preference-line"><strong>偏慎</strong><p>${preferenceTags(tenGods.caution)}</p></div></section>
|
||||
<section><span>五行喜忌</span><div class="personal-preference-line"><strong>偏喜</strong><p>${preferenceTags(elementTendency.favorable)}</p></div><div class="personal-preference-line"><strong>偏忌</strong><p>${preferenceTags(elementTendency.caution)}</p></div></section>
|
||||
</div>
|
||||
</div>`;
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function renderHexagramLines(containerId, lines, includeEvidence = false) {
|
||||
@@ -1091,6 +1114,27 @@ const HEAVEN_READING_META = {
|
||||
heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解" },
|
||||
};
|
||||
|
||||
const HEAVEN_ANSWER_HEADINGS = [
|
||||
"核心判断", "卦势依据", "动爻转折", "之卦趋向", "决策映射",
|
||||
"年纲", "客主加临", "日辰触发", "行业影响", "个人合参", "制衡动作",
|
||||
"所问之答", "卦象依据", "动变与之卦", "可验证之处",
|
||||
"主要矛盾", "变化方向", "交易映射", "可验证动作",
|
||||
];
|
||||
|
||||
function formatHeavenAnswer(content) {
|
||||
const labels = HEAVEN_ANSWER_HEADINGS.join("|");
|
||||
let normalized = String(content || "").replace(/\r\n?/g, "\n");
|
||||
normalized = normalized.replace(
|
||||
new RegExp(`(^|\\n)\\s*\\*\\*(${labels})[::]?\\*\\*\\s*`, "g"),
|
||||
(_match, prefix, heading) => `${prefix}## ${heading}\n\n`,
|
||||
);
|
||||
normalized = normalized.replace(
|
||||
new RegExp(`(^|[。!?;]\\s*|\\n)(${labels})[::]\\s*`, "g"),
|
||||
(_match, prefix, heading) => `${prefix}\n\n## ${heading}\n\n`,
|
||||
);
|
||||
return formatMentorAnswer(normalized.replace(/\n{3,}/g, "\n\n"));
|
||||
}
|
||||
|
||||
function heavenReadingMeta(mode = state.heavenReadingMode) {
|
||||
return HEAVEN_READING_META[mode] || HEAVEN_READING_META.trend;
|
||||
}
|
||||
@@ -1216,7 +1260,7 @@ function renderHeavenReadingCurrent() {
|
||||
setText("heavenReadingSubject", reading.subject || `${heavenReadingMeta().panel}解读`);
|
||||
setText("heavenReadingSubjectDetail", reading.subject_detail || displayCompactDate(reading.context_date || ""));
|
||||
setText("heavenReadingCreatedAt", reading.created_at ? formatTimestamp(reading.created_at) : "刚刚完成");
|
||||
document.querySelector("#heavenReadingAnswer").innerHTML = formatMentorAnswer(reading.answer || "");
|
||||
document.querySelector("#heavenReadingAnswer").innerHTML = formatHeavenAnswer(reading.answer || "");
|
||||
}
|
||||
|
||||
function renderHeavenReadingHistory() {
|
||||
@@ -1237,7 +1281,7 @@ function renderHeavenReadingHistory() {
|
||||
detail.innerHTML = selected ? `
|
||||
<header><div><span>${escapeHtml(heavenReadingMeta(mode).status)}</span><h3>${escapeHtml(selected.subject)}</h3></div><time>${formatTimestamp(selected.created_at)}</time></header>
|
||||
<p>${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}</p>
|
||||
<div class="heaven-reading-answer">${formatMentorAnswer(selected.answer || "")}</div>
|
||||
<div class="heaven-reading-answer">${formatHeavenAnswer(selected.answer || "")}</div>
|
||||
<footer><button class="button" type="button" data-delete-heaven-reading="${number(selected.id)}"><i data-lucide="trash-2"></i><span>删除记录</span></button></footer>
|
||||
` : emptyStateHtml("选择一条记录查看完整解读");
|
||||
refreshIcons();
|
||||
@@ -1292,7 +1336,13 @@ async function interpretHeaven(mode) {
|
||||
stock_code: state.heavenSetup?.chart?.stock?.code || "",
|
||||
};
|
||||
if (mode === "trend" && state.heavenManualData) payload.manual_data = state.heavenManualData;
|
||||
if (mode === "heart") payload.lines = state.heartLines;
|
||||
if (mode === "heart") {
|
||||
payload.trade_date = todayString();
|
||||
payload.lines = state.heartLines;
|
||||
payload.question = state.heartQuestion;
|
||||
payload.question_preset = state.heartQuestionPreset;
|
||||
payload.cast_at = state.heartCastAt;
|
||||
}
|
||||
const result = await apiRequest("/api/heaven/interpret", "POST", payload);
|
||||
state.heavenInterpretations[mode] = result.reading || {
|
||||
answer: result.answer,
|
||||
@@ -1457,6 +1507,12 @@ function waitForHeartMotion(duration, token = state.heartStageToken) {
|
||||
}
|
||||
|
||||
async function startHeartBreathing() {
|
||||
syncHeartQuestion();
|
||||
if (!state.heartQuestion) {
|
||||
showToast("请先写下问题,或选择无题观心");
|
||||
return;
|
||||
}
|
||||
setHeartQuestionLocked(true);
|
||||
if (state.heartTimer) clearInterval(state.heartTimer);
|
||||
state.heartSeconds = HEART_BREATH_TOTAL_MS / 1000;
|
||||
state.heartBreathingEndsAt = 0;
|
||||
@@ -1534,6 +1590,7 @@ async function beginHeartCasting() {
|
||||
state.heartLines = [];
|
||||
state.heartThrows = [];
|
||||
state.heartHexagram = null;
|
||||
state.heartCastAt = "";
|
||||
state.heavenInterpretations.heart = "";
|
||||
heartCastingBusy = false;
|
||||
resetHeartCoins();
|
||||
@@ -1598,6 +1655,7 @@ async function tossHeartCoins() {
|
||||
return;
|
||||
}
|
||||
const stageToken = state.heartStageToken;
|
||||
if (!state.heartCastAt) state.heartCastAt = new Date().toISOString();
|
||||
const button = document.querySelector("#tossCoinsButton");
|
||||
heartCastingBusy = true;
|
||||
button.disabled = true;
|
||||
@@ -1872,6 +1930,7 @@ async function resetHeartRitual() {
|
||||
state.heartLines = [];
|
||||
state.heartThrows = [];
|
||||
state.heartHexagram = null;
|
||||
state.heartCastAt = "";
|
||||
state.heavenInterpretations.heart = "";
|
||||
heartIncenseAnimation?.cancel();
|
||||
heartIncenseAnimation = null;
|
||||
@@ -1881,9 +1940,45 @@ async function resetHeartRitual() {
|
||||
heartCastingBusy = false;
|
||||
resetHeartCoins();
|
||||
hideHeavenNotice();
|
||||
setHeartQuestionLocked(false);
|
||||
syncHeartQuestion();
|
||||
await transitionHeartStage("intro");
|
||||
}
|
||||
|
||||
function applyHeartQuestionPreset(preset) {
|
||||
if (!(preset in HEART_QUESTION_PRESETS) || state.heartStage !== "intro") return;
|
||||
const input = document.querySelector("#heartQuestionInput");
|
||||
state.heartQuestionPreset = preset;
|
||||
input.value = HEART_QUESTION_PRESETS[preset];
|
||||
syncHeartQuestion();
|
||||
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(button.dataset.heartQuestionPreset === preset));
|
||||
});
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
|
||||
function syncHeartQuestion() {
|
||||
const input = document.querySelector("#heartQuestionInput");
|
||||
if (!input) return;
|
||||
state.heartQuestion = input.value.trim();
|
||||
const matchedPreset = Object.entries(HEART_QUESTION_PRESETS)
|
||||
.find(([, question]) => question === state.heartQuestion)?.[0];
|
||||
state.heartQuestionPreset = matchedPreset || "custom";
|
||||
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(button.dataset.heartQuestionPreset === matchedPreset));
|
||||
});
|
||||
document.querySelector("#startBreathingButton").disabled = !state.heartQuestion;
|
||||
}
|
||||
|
||||
function setHeartQuestionLocked(locked) {
|
||||
const input = document.querySelector("#heartQuestionInput");
|
||||
if (input) input.disabled = locked;
|
||||
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
|
||||
button.disabled = locked;
|
||||
});
|
||||
}
|
||||
|
||||
function showHeavenNotice(message) {
|
||||
const notice = document.querySelector("#heavenNotice");
|
||||
notice.textContent = message;
|
||||
@@ -1898,6 +1993,10 @@ function phaseClass(element) {
|
||||
return { 木: "wood", 火: "fire", 土: "earth", 金: "metal", 水: "water" }[element] || "earth";
|
||||
}
|
||||
|
||||
function phaseIcon(element) {
|
||||
return { 木: "sprout", 火: "flame", 土: "mountain", 金: "gem", 水: "waves" }[element] || "circle-dot";
|
||||
}
|
||||
|
||||
function signedScore(value) {
|
||||
const parsed = number(value);
|
||||
return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`;
|
||||
@@ -1944,6 +2043,10 @@ function bindHeavenEvents() {
|
||||
document.querySelector("#openPersonalSettingsButton").addEventListener("click", () => openSettings("profile"));
|
||||
document.querySelector("#sectorPhaseForm").addEventListener("submit", saveSectorPhaseOverride);
|
||||
document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing);
|
||||
document.querySelector("#heartQuestionInput").addEventListener("input", syncHeartQuestion);
|
||||
document.querySelectorAll("[data-heart-question-preset]").forEach((button) => {
|
||||
button.addEventListener("click", () => applyHeartQuestionPreset(button.dataset.heartQuestionPreset));
|
||||
});
|
||||
document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting);
|
||||
document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound);
|
||||
document.querySelector("#historyHeartButton").addEventListener("click", () => openHeavenHistory("heart"));
|
||||
@@ -1962,4 +2065,5 @@ function bindHeavenEvents() {
|
||||
document.querySelectorAll("[data-heart-return]").forEach((button) => {
|
||||
button.addEventListener("click", resetHeartRitual);
|
||||
});
|
||||
syncHeartQuestion();
|
||||
}
|
||||
|
||||
+173
@@ -313,6 +313,10 @@
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#screenerView.mobile-results [data-screener-panel]:not([hidden]) {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
#screenerView.mobile-results [data-screener-panel]:not([hidden]) > :not([data-screener-results-slot]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -6431,3 +6435,172 @@ body[data-active-view="screenerView"] .overview-strip {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
#screenerView .screener-result-tools {
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: flex-end;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-tabs {
|
||||
min-height: 30px;
|
||||
|
||||
height: 30px;
|
||||
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-tabs .segment {
|
||||
min-height: 26px;
|
||||
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
#screenerView .screener-batch-notice {
|
||||
min-height: 38px;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 12px;
|
||||
|
||||
padding: 8px 14px;
|
||||
|
||||
border-top: 1px solid var(--r2-line-soft);
|
||||
|
||||
background: var(--r2-card);
|
||||
|
||||
color: var(--r2-faint);
|
||||
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
#screenerView .screener-batch-notice strong {
|
||||
flex: 0 0 auto;
|
||||
|
||||
color: var(--r2-ink);
|
||||
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
#screenerView .screener-batch-notice.warning {
|
||||
background: var(--warning-soft);
|
||||
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-table {
|
||||
min-width: 840px;
|
||||
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-table th:nth-child(1) {
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-table th:nth-child(2) {
|
||||
width: 132px;
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-table th:nth-child(3) {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-table th:nth-child(4) {
|
||||
width: 92px;
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-table th:nth-child(6) {
|
||||
width: 94px;
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-table th:nth-child(7) {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-table :is(th, td):nth-child(-n+3),
|
||||
#screenerView .screener-archive-table :is(th, td):last-child {
|
||||
position: static;
|
||||
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#screenerView .screener-archive-strategies {
|
||||
overflow: hidden;
|
||||
|
||||
color: var(--r2-sub);
|
||||
|
||||
text-overflow: ellipsis;
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#screenerView .screener-signal-status {
|
||||
display: inline-flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
min-height: 22px;
|
||||
|
||||
padding: 0 7px;
|
||||
|
||||
border-radius: 5px;
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
font-size: 10.5px;
|
||||
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#screenerView .screener-signal-status.active {
|
||||
background: var(--market-up-soft);
|
||||
|
||||
color: var(--market-up);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] #screenerView :is(.screener-batch-notice, .screener-archive-tabs) {
|
||||
border-color: var(--border);
|
||||
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] #screenerView .screener-batch-notice.warning {
|
||||
background: var(--warning-soft);
|
||||
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#screenerView .result-toolbar {
|
||||
align-items: flex-start;
|
||||
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#screenerView .screener-result-tools {
|
||||
width: 100%;
|
||||
|
||||
align-items: flex-start;
|
||||
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#screenerView .screener-batch-notice {
|
||||
align-items: flex-start;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,10 +149,18 @@
|
||||
</section>
|
||||
<div class="section-toolbar result-toolbar">
|
||||
<div class="section-title-group"><h2 id="screenerResultTitle">候选结果</h2><span id="screenerResultCount" class="count-badge">0 只</span><span id="screenerResultSource" class="screener-result-source" hidden></span></div>
|
||||
<span id="screenerDisclaimer" class="section-subtitle">历史统计不代表未来收益</span>
|
||||
<div class="screener-result-tools">
|
||||
<div class="segmented screener-archive-tabs" role="tablist" aria-label="候选结果范围">
|
||||
<button class="segment active" type="button" role="tab" aria-selected="true" data-screener-archive-view="latest">最新候选</button>
|
||||
<button class="segment" type="button" role="tab" aria-selected="false" data-screener-archive-view="active">持续有效</button>
|
||||
<button class="segment" type="button" role="tab" aria-selected="false" data-screener-archive-view="history">入选历史</button>
|
||||
</div>
|
||||
<span id="screenerDisclaimer" class="section-subtitle">历史统计不代表未来收益</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="screenerBatchNotice" class="screener-batch-notice" hidden></div>
|
||||
<div class="table-frame card tbl-wrap screener-result-frame">
|
||||
<table class="data-table tbl">
|
||||
<table id="screenerLatestTable" class="data-table tbl">
|
||||
<colgroup class="screener-result-columns"><col><col><col><col><col><col><col><col><col><col><col><col></colgroup>
|
||||
<thead><tr>
|
||||
<th class="num">排名</th><th>股票</th><th>板块</th><th class="number num sortable">综合分<span class="arr">↕</span></th>
|
||||
@@ -161,6 +169,12 @@
|
||||
</tr></thead>
|
||||
<tbody id="screenerTableBody"></tbody>
|
||||
</table>
|
||||
<table id="screenerArchiveTable" class="data-table tbl screener-archive-table" hidden>
|
||||
<thead><tr>
|
||||
<th>入选日</th><th>股票</th><th>板块</th><th>状态</th><th>命中策略</th><th class="number num sortable">最新评分<span class="arr">↕</span></th><th>有效期</th>
|
||||
</tr></thead>
|
||||
<tbody id="screenerArchiveTableBody"></tbody>
|
||||
</table>
|
||||
<div id="screenerEmpty" class="empty-state">尚未执行选股</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,15 @@ function selectedScreenerResultKey(mode) {
|
||||
|
||||
function activeScreenerResultEntry(mode = state.screenerMode) {
|
||||
const key = selectedScreenerResultKey(mode);
|
||||
return key ? state.screenerResultStore[key] || null : null;
|
||||
const stored = key ? state.screenerResultStore[key] || null : null;
|
||||
if (stored) return stored;
|
||||
const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
|
||||
const latest = state.screenerResults[normalizedMode];
|
||||
const context = state.screenerResultContexts[normalizedMode];
|
||||
if (!latest || !context) return null;
|
||||
if (normalizedMode === "quant") return { result: latest, context };
|
||||
const selected = currentScreenerStrategy(normalizedMode);
|
||||
return selected?.name === context.strategyName ? { result: latest, context } : null;
|
||||
}
|
||||
|
||||
function activeScreenerResult(mode = state.screenerMode) {
|
||||
@@ -84,11 +92,10 @@ function setScreenerResult(mode, result, options = {}) {
|
||||
|
||||
function applyScreenerSetup(payload, requestKey) {
|
||||
const dateChanged = Boolean(state.screenerSetupKey && state.screenerSetupKey !== requestKey);
|
||||
if (dateChanged) {
|
||||
state.screenerResults = { smart: null, curated: null, quant: null };
|
||||
state.screenerResultContexts = { smart: null, curated: null, quant: null };
|
||||
state.screenerResultStore = {};
|
||||
}
|
||||
state.screenerResults = { smart: null, curated: null, quant: null };
|
||||
state.screenerResultContexts = { smart: null, curated: null, quant: null };
|
||||
state.screenerResultStore = {};
|
||||
if (dateChanged) state.screenerArchiveView = "latest";
|
||||
state.screenerSetup = payload;
|
||||
state.screenerSetupKey = requestKey;
|
||||
|
||||
@@ -182,7 +189,13 @@ async function loadScreenerSetup(force = false) {
|
||||
function renderScreenerSetup() {
|
||||
const setup = state.screenerSetup;
|
||||
if (!setup) return;
|
||||
setText("screenerDateLabel", `数据日期 ${displayCompactDate(setup.trade_date)}`);
|
||||
const publishedDate = setup.published_batch?.trade_date;
|
||||
setText(
|
||||
"screenerDateLabel",
|
||||
publishedDate && publishedDate !== setup.trade_date
|
||||
? `数据日期 ${displayCompactDate(setup.trade_date)} · 候选批次 ${displayCompactDate(publishedDate)}`
|
||||
: `数据日期 ${displayCompactDate(setup.trade_date)}`,
|
||||
);
|
||||
setText("regimeLabel", setup.regime.label);
|
||||
setText("regimeConfidence", `置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
|
||||
setText("regimeStepStatus", `${setup.regime.label} · 置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
|
||||
@@ -233,6 +246,7 @@ function renderStrategySummary() {
|
||||
|
||||
function selectScreenerMode(mode) {
|
||||
state.screenerMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
|
||||
state.screenerArchiveView = "latest";
|
||||
localStorage.setItem("xiaobaiScreenerMode", state.screenerMode);
|
||||
state.screenerMobileView = "strategy";
|
||||
renderScreenerMode();
|
||||
@@ -293,6 +307,17 @@ function curatedSchoolIcon(school) {
|
||||
}
|
||||
|
||||
function curatedStrategyRunState(strategy, result) {
|
||||
const publishedRun = strategy?.published_run || {};
|
||||
if (publishedRun.status === "missing_data") {
|
||||
return { label: "数据不足", className: "missing", verifiedEmpty: false };
|
||||
}
|
||||
if (publishedRun.status === "no_signal") {
|
||||
return { label: "暂无信号", className: "quiet", verifiedEmpty: true };
|
||||
}
|
||||
if (publishedRun.status === "ready") {
|
||||
const count = (result?.candidates || []).length;
|
||||
return { label: `${count} 只候选`, className: "ready", verifiedEmpty: false };
|
||||
}
|
||||
const missingData = strategy?.missing_data || [];
|
||||
if (!strategy?.data_ready || missingData.length) {
|
||||
return { label: "数据不足", className: "missing", verifiedEmpty: false };
|
||||
@@ -397,15 +422,24 @@ function renderCuratedStrategyDetail() {
|
||||
}
|
||||
document.querySelector("#curatedHealthMetrics").innerHTML = [
|
||||
["运行状态", statusLabel, statusClass],
|
||||
["当日信号", result ? `${candidateCount} 只` : "--", ""],
|
||||
["批次信号", result ? `${candidateCount} 只` : "--", ""],
|
||||
["字段覆盖", health.coverage != null ? `${formatNumber(health.coverage, 1)}%` : strategy.data_ready ? "数据已就绪" : "--", ""],
|
||||
["最近更新", updatedLabel, ""],
|
||||
].map(([label, value, className]) => `<div><span>${escapeHtml(label)}</span><strong class="${className}">${escapeHtml(value)}</strong></div>`).join("");
|
||||
const status = document.querySelector("#curatedDataStatus");
|
||||
status.classList.toggle("missing", statusClass === "missing");
|
||||
status.innerHTML = strategy.data_ready
|
||||
? `<i data-lucide="${runState.verifiedEmpty ? "circle-check" : "database"}"></i><span><strong>${runState.verifiedEmpty ? "本日暂无信号" : "盘后自动更新"}</strong><small>${runState.verifiedEmpty ? `必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件` : result ? health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入" : "等待当日行情定格后生成"}</small></span>`
|
||||
: `<i data-lucide="circle-alert"></i><span><strong>数据尚未完备</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}</small></span>`;
|
||||
const publishedRun = strategy.published_run || {};
|
||||
if (publishedRun.status === "missing_data") {
|
||||
status.innerHTML = `<i data-lucide="circle-alert"></i><span><strong>缺少必需数据</strong><small>${escapeHtml(publishedRun.detail || "等待后台同步")}</small></span>`;
|
||||
} else if (publishedRun.status === "no_signal") {
|
||||
status.innerHTML = `<i data-lucide="circle-check"></i><span><strong>数据完整,暂无信号</strong><small>必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件</small></span>`;
|
||||
} else if (result) {
|
||||
status.innerHTML = `<i data-lucide="database"></i><span><strong>盘后批次已发布</strong><small>${health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入"}</small></span>`;
|
||||
} else if (!strategy.data_ready) {
|
||||
status.innerHTML = `<i data-lucide="circle-alert"></i><span><strong>数据尚未完备</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}</small></span>`;
|
||||
} else {
|
||||
status.innerHTML = "<i data-lucide=\"clock-3\"></i><span><strong>等待成功批次</strong><small>运行中或失败的批次不会覆盖上一成功结果</small></span>";
|
||||
}
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
@@ -842,22 +876,163 @@ async function executeScreenerFormula({ mode, formula, strategyName, strategyId
|
||||
}
|
||||
|
||||
function renderScreenerResult() {
|
||||
renderScreenerArchiveTabs();
|
||||
renderScreenerBatchNotice();
|
||||
if (state.screenerArchiveView === "latest") {
|
||||
renderLatestScreenerResult();
|
||||
} else {
|
||||
renderScreenerArchiveResult();
|
||||
}
|
||||
}
|
||||
|
||||
function renderScreenerArchiveTabs() {
|
||||
document.querySelectorAll("[data-screener-archive-view]").forEach((button) => {
|
||||
const active = button.dataset.screenerArchiveView === state.screenerArchiveView;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
});
|
||||
}
|
||||
|
||||
function renderScreenerBatchNotice() {
|
||||
const notice = document.querySelector("#screenerBatchNotice");
|
||||
const setup = state.screenerSetup || {};
|
||||
const batch = setup.published_batch;
|
||||
const requestStatus = setup.automatic_status || {};
|
||||
notice.classList.toggle("warning", Boolean(batch?.is_fallback) || ["failed", "partial"].includes(requestStatus.status));
|
||||
if (state.screenerMode === "quant") {
|
||||
notice.innerHTML = "<strong>手动执行</strong><span>自定义选股结果独立保存,不会覆盖阶段选股或策略选股。</span>";
|
||||
notice.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (!batch) {
|
||||
const statusText = {
|
||||
running: "盘后候选正在生成,完成前不会发布局部结果。",
|
||||
failed: "最近一次盘后任务失败,尚无可展示的成功批次。",
|
||||
partial: "最近一次盘后任务未完整完成,尚无可展示的成功批次。",
|
||||
pending: "盘后候选尚未生成。",
|
||||
}[requestStatus.status] || "尚无成功发布的盘后候选批次。";
|
||||
notice.innerHTML = `<strong>尚未发布</strong><span>${escapeHtml(statusText)}</span>`;
|
||||
notice.hidden = false;
|
||||
return;
|
||||
}
|
||||
const completed = number(batch.completed_count);
|
||||
const skipped = number(batch.skipped_count);
|
||||
const detail = batch.notice
|
||||
|| `盘后候选已完整发布${completed ? ` · 完成 ${completed} 套策略` : ""}${skipped ? ` · ${skipped} 套因数据不足跳过` : ""}`;
|
||||
const time = batch.finished_at ? ` · ${formatTimestamp(batch.finished_at)}` : "";
|
||||
notice.innerHTML = `<strong>候选基准 ${escapeHtml(displayCompactDate(batch.trade_date))}${time}</strong><span>${escapeHtml(detail)}</span>`;
|
||||
notice.hidden = false;
|
||||
}
|
||||
|
||||
function selectedScreenerArchiveRows() {
|
||||
const mode = state.screenerMode || "smart";
|
||||
const source = state.screenerArchiveView === "active"
|
||||
? state.screenerSetup?.active_signals || []
|
||||
: state.screenerSetup?.candidate_history || [];
|
||||
const strategy = currentScreenerStrategy(mode);
|
||||
return source.filter((row) => {
|
||||
if (row.mode !== mode) return false;
|
||||
if (mode === "quant") return true;
|
||||
return (row.hits || []).some((hit) => hit.strategy_name === strategy?.name);
|
||||
}).map((row) => {
|
||||
let hits = row.hits || [];
|
||||
if (mode !== "quant") hits = hits.filter((hit) => hit.strategy_name === strategy?.name);
|
||||
if (state.screenerArchiveView === "active") hits = hits.filter((hit) => hit.active);
|
||||
const latestHit = [...hits].sort((left, right) => String(right.selection_date).localeCompare(String(left.selection_date)))[0] || {};
|
||||
const active = hits.some((hit) => hit.active);
|
||||
const labels = [...new Set(hits.map((hit) => hit.validity?.label).filter(Boolean))];
|
||||
return {
|
||||
...row,
|
||||
selection_date: latestHit.selection_date || row.selection_date,
|
||||
score_display: latestHit.score_display ?? row.score_display,
|
||||
matched_strategies: [...new Set(hits.map((hit) => hit.strategy_name).filter(Boolean))],
|
||||
status: active ? "持续有效" : "已到期",
|
||||
active,
|
||||
validity_label: labels.join(" / ") || row.validity_label || "--",
|
||||
valid_until: latestHit.valid_until || "",
|
||||
remaining_trading_days: latestHit.remaining_trading_days,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderScreenerArchiveResult() {
|
||||
const mode = state.screenerMode || "smart";
|
||||
const rows = selectedScreenerArchiveRows();
|
||||
setText("screenerResultTitle", state.screenerArchiveView === "active" ? "持续有效信号" : "历史入选记录");
|
||||
const latestTable = document.querySelector("#screenerLatestTable");
|
||||
const archiveTable = document.querySelector("#screenerArchiveTable");
|
||||
latestTable.hidden = true;
|
||||
archiveTable.hidden = false;
|
||||
setText("screenerResultCount", `${rows.length} 只`);
|
||||
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
|
||||
const strategy = currentScreenerStrategy(mode);
|
||||
const source = document.querySelector("#screenerResultSource");
|
||||
source.textContent = [modeLabels[mode], strategy?.name].filter(Boolean).join(" · ");
|
||||
source.hidden = false;
|
||||
setText(
|
||||
"screenerDisclaimer",
|
||||
state.screenerArchiveView === "active"
|
||||
? "持续有效按策略频率与阶段变化计算,不代表未来收益"
|
||||
: "历史入选仅保留当时策略结果,不使用未来数据回写",
|
||||
);
|
||||
const body = document.querySelector("#screenerArchiveTableBody");
|
||||
body.innerHTML = rows.map((row) => {
|
||||
const validity = row.valid_until
|
||||
? `${row.validity_label} · 至 ${displayCompactDate(row.valid_until)}`
|
||||
: row.validity_label;
|
||||
const matched = (row.matched_strategies || []).join("、");
|
||||
return `<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="num">${escapeHtml(displayCompactDate(row.selection_date))}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td>${escapeHtml(row.sector)}</td>
|
||||
<td><span class="screener-signal-status ${row.active ? "active" : "expired"}">${escapeHtml(row.status)}</span></td>
|
||||
<td class="screener-archive-strategies" title="${escapeHtml(matched)}">${escapeHtml(matched)}</td>
|
||||
<td class="number num">${row.score_display == null ? "" : formatNumber(row.score_display, 1)}</td>
|
||||
<td>${escapeHtml(validity)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
bindStockRows(body);
|
||||
const empty = document.querySelector("#screenerEmpty");
|
||||
empty.textContent = state.screenerArchiveView === "active"
|
||||
? "当前策略暂无仍在有效期内的信号"
|
||||
: "当前策略暂无历史入选记录";
|
||||
empty.hidden = rows.length > 0;
|
||||
renderBacktest(null);
|
||||
updateBacktestTaskStatus();
|
||||
}
|
||||
|
||||
function latestScreenerEmptyMessage(mode) {
|
||||
if (mode === "quant") return "尚未执行自定义选股";
|
||||
const strategy = currentScreenerStrategy(mode);
|
||||
const publishedRun = strategy?.published_run || {};
|
||||
if (publishedRun.status === "missing_data") {
|
||||
return `缺少必需数据:${publishedRun.detail || "等待后台同步"}`;
|
||||
}
|
||||
if (publishedRun.status === "no_signal") return "数据完整,暂无符合条件个股";
|
||||
const status = state.screenerSetup?.automatic_status?.status;
|
||||
if (status === "running") return "盘后候选正在生成,成功发布前不会显示局部结果";
|
||||
if (status === "failed") return "本次盘后任务失败,当前策略暂无已发布结果";
|
||||
if (status === "partial") return "本次盘后任务未完整完成,当前策略暂无已发布结果";
|
||||
return "尚无成功发布的盘后候选结果";
|
||||
}
|
||||
|
||||
function renderLatestScreenerResult() {
|
||||
const mode = state.screenerMode || "smart";
|
||||
const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" };
|
||||
setText("screenerResultTitle", titles[mode]);
|
||||
const result = activeScreenerResult(mode);
|
||||
const context = activeScreenerResultContext(mode);
|
||||
const source = document.querySelector("#screenerResultSource");
|
||||
const emptyMessages = {
|
||||
smart: "当日盘后候选尚未生成",
|
||||
curated: "所选策略的当日候选尚未生成",
|
||||
quant: "尚未执行自定义选股",
|
||||
};
|
||||
const emptyMessage = latestScreenerEmptyMessage(mode);
|
||||
document.querySelector("#screenerLatestTable").hidden = false;
|
||||
document.querySelector("#screenerArchiveTable").hidden = true;
|
||||
if (!result) {
|
||||
setText("screenerResultCount", "0 只");
|
||||
source.hidden = true;
|
||||
source.textContent = "";
|
||||
setText("screenerDisclaimer", "历史统计不代表未来收益");
|
||||
document.querySelector("#screenerTableBody").innerHTML = "";
|
||||
document.querySelector("#screenerEmpty").textContent = emptyMessages[mode];
|
||||
document.querySelector("#screenerEmpty").textContent = emptyMessage;
|
||||
document.querySelector("#screenerEmpty").hidden = false;
|
||||
renderBacktest(null);
|
||||
if (mode === "smart") setText("screenerRunStatus", "等待执行");
|
||||
@@ -881,7 +1056,10 @@ function renderScreenerResult() {
|
||||
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
|
||||
);
|
||||
const empty = document.querySelector("#screenerEmpty");
|
||||
empty.textContent = mode === "curated" ? "暂无符合条件个股" : emptyMessages[mode];
|
||||
const verifiedEmpty = mode === "curated" ? "暂无符合条件个股" : "数据完整,暂无符合条件个股";
|
||||
empty.textContent = mode === "quant"
|
||||
? emptyMessage
|
||||
: `${verifiedEmpty}${mode === "curated" ? "(必需数据已完整)" : ""}`;
|
||||
empty.hidden = candidates.length > 0;
|
||||
const body = document.querySelector("#screenerTableBody");
|
||||
const runId = number(meta.run_id);
|
||||
@@ -1053,7 +1231,20 @@ function parseFormulaEditor() {
|
||||
}
|
||||
|
||||
function exportScreenerResults() {
|
||||
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" };
|
||||
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
|
||||
if (state.screenerArchiveView !== "latest") {
|
||||
const viewLabel = state.screenerArchiveView === "active" ? "持续有效" : "入选历史";
|
||||
const rows = selectedScreenerArchiveRows().map((row) => ({
|
||||
...row,
|
||||
matched_strategy_names: (row.matched_strategies || []).join("、"),
|
||||
}));
|
||||
exportRows(`${modeLabels[state.screenerMode]}-${viewLabel}`, rows, [
|
||||
["入选日", "selection_date"], ["股票代码", "code"], ["股票名称", "name"],
|
||||
["板块", "sector"], ["状态", "status"], ["命中策略", "matched_strategy_names"],
|
||||
["最新评分", "score_display"], ["有效期", "validity_label"],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
exportRows(modeLabels[state.screenerMode] || "智能选股", activeScreenerResult()?.candidates || [], [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["板块", "sector"], ["综合分", "score_display"],
|
||||
["历史条件估计%", "historical_probability"], ["当日涨幅%", "pct_chg"], ["5日涨幅%", "return_5d"],
|
||||
@@ -1091,6 +1282,14 @@ function bindScreenerEvents() {
|
||||
document.querySelectorAll("[data-screener-mode]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectScreenerMode(button.dataset.screenerMode));
|
||||
});
|
||||
document.querySelectorAll("[data-screener-archive-view]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.screenerArchiveView = ["active", "history"].includes(button.dataset.screenerArchiveView)
|
||||
? button.dataset.screenerArchiveView
|
||||
: "latest";
|
||||
renderScreenerResult();
|
||||
});
|
||||
});
|
||||
document.querySelector("#curatedStrategyList").addEventListener("click", (event) => {
|
||||
if (event.target.closest("button")) return;
|
||||
const card = event.target.closest("[data-curated-strategy]");
|
||||
|
||||
Reference in New Issue
Block a user