feat: complete heaven readings and screener publication

This commit is contained in:
leefer
2026-08-05 23:44:17 +08:00
parent 6a058c2929
commit b3a21d05b7
34 changed files with 3538 additions and 225 deletions
+133 -67
View File
@@ -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;
+11 -4
View File
@@ -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>
+134 -30
View File
@@ -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();
}