feat: persist heaven readings in unified dialog

This commit is contained in:
leefer
2026-07-23 09:51:33 +08:00
parent a517f9d859
commit c62670d46b
10 changed files with 739 additions and 22 deletions
+3
View File
@@ -13,6 +13,7 @@ MEMBER_GET_PATHS = frozenset(
"/api/mentors/setup",
"/api/mentors/messages",
"/api/heaven/setup",
"/api/heaven/readings",
"/api/assistant/messages",
}
)
@@ -60,4 +61,6 @@ def required_role(method: str, path: str) -> AccessRole:
r"/api/screener/strategies/\d+", path
):
return "member"
if re.fullmatch(r"/api/heaven/readings/\d+", path):
return "member"
return "authenticated"
+121
View File
@@ -334,6 +334,24 @@ class ReviewDatabase:
CREATE INDEX IF NOT EXISTS idx_assistant_messages_user
ON assistant_messages(user_id, id DESC);
CREATE TABLE IF NOT EXISTS heaven_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
mode TEXT NOT NULL,
context_date TEXT NOT NULL,
subject TEXT NOT NULL,
subject_detail TEXT NOT NULL DEFAULT '',
answer TEXT NOT NULL,
context_snapshot TEXT NOT NULL DEFAULT '{}',
dedupe_key TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(user_id, dedupe_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_heaven_readings_user_mode
ON heaven_readings(user_id, mode, context_date DESC, id DESC);
"""
)
user_columns = {
@@ -1720,6 +1738,109 @@ class ReviewDatabase:
)
return int(cursor.rowcount)
@staticmethod
def _heaven_reading_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if not row:
return None
return {
"id": int(row["id"]),
"mode": str(row["mode"]),
"context_date": str(row["context_date"]),
"subject": str(row["subject"]),
"subject_detail": str(row["subject_detail"]),
"answer": str(row["answer"]),
"created_at": str(row["created_at"]),
}
def save_heaven_reading(
self,
user_id: int,
mode: str,
context_date: str,
subject: str,
subject_detail: str,
answer: str,
context_snapshot: dict[str, Any],
dedupe_key: str,
) -> dict[str, Any]:
now = datetime.now().astimezone().isoformat(timespec="seconds")
snapshot_json = json.dumps(
context_snapshot, ensure_ascii=False, separators=(",", ":")
)
with self.connect() as connection:
connection.execute(
"""
INSERT INTO heaven_readings
(user_id, mode, context_date, subject, subject_detail, answer,
context_snapshot, dedupe_key, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, dedupe_key) DO NOTHING
""",
(
int(user_id), mode, context_date, subject, subject_detail,
answer, snapshot_json, dedupe_key, now,
),
)
row = connection.execute(
"""
SELECT id, mode, context_date, subject, subject_detail, answer, created_at
FROM heaven_readings WHERE user_id = ? AND dedupe_key = ?
""",
(int(user_id), dedupe_key),
).fetchone()
connection.execute(
"""
DELETE FROM heaven_readings
WHERE user_id = ? AND mode = ? AND id NOT IN (
SELECT id FROM heaven_readings
WHERE user_id = ? AND mode = ? ORDER BY id DESC LIMIT 100
)
""",
(int(user_id), mode, int(user_id), mode),
)
result = self._heaven_reading_dict(row)
if not result:
raise ValueError("解读记录保存失败。")
return result
def list_heaven_readings(
self,
user_id: int,
mode: str,
context_date: str = "",
limit: int = 100,
) -> list[dict[str, Any]]:
clauses = ["user_id = ?", "mode = ?"]
parameters: list[Any] = [int(user_id), mode]
if context_date:
clauses.append("context_date = ?")
parameters.append(context_date)
parameters.append(max(1, min(100, int(limit))))
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT id, mode, context_date, subject, subject_detail, answer, created_at
FROM heaven_readings WHERE {' AND '.join(clauses)}
ORDER BY context_date DESC, id DESC LIMIT ?
""",
parameters,
).fetchall()
return [self._heaven_reading_dict(row) for row in rows if row]
def latest_heaven_reading(
self, user_id: int, mode: str, context_date: str = ""
) -> dict[str, Any] | None:
items = self.list_heaven_readings(user_id, mode, context_date, 1)
return items[0] if items else None
def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM heaven_readings WHERE id = ? AND user_id = ?",
(int(reading_id), int(user_id)),
)
return cursor.rowcount > 0
def start_sync(self, trade_date: str, source: str) -> int:
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
+105
View File
@@ -2023,6 +2023,9 @@ class DashboardService:
"chart": chart,
"field": field,
"personal_profile": personal_profile,
"daily_fortune_reading": self.database.latest_heaven_reading(
self.current_user_id, "fortune", normalized_date
),
"sector_phase_overrides": [
{"name": name, "element": element}
for name, element in sector_phase_overrides.items()
@@ -2286,11 +2289,69 @@ class DashboardService:
raise ValueError("六爻必须由六、七、八、九组成。") from exc
return hexagram_from_lines(lines)
def heaven_readings(
self, mode: str, context_date: str = "", limit: int = 100
) -> dict[str, Any]:
mode = str(mode or "").strip()
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("解读记录类型不正确。")
normalized_date = normalize_date(context_date) if context_date else ""
return {
"mode": mode,
"items": self.database.list_heaven_readings(
self.current_user_id, mode, normalized_date, limit
),
}
@staticmethod
def _heaven_reading_identity(
mode: str, context_date: str, context: dict[str, Any]
) -> tuple[str, str]:
display_date = DashboardService._display_compact_date(context_date)
if mode == "trend":
stock = (context.get("selected_focus") or {}).get("stock") or {}
code = str(stock.get("code") or "").strip()
name = str(stock.get("name") or "").strip()
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
subject = " ".join(item for item in (code, name) if item) or "观势"
detail = f"{display_date} · {hexagram.get('name') or '--'}{transformed.get('name') or '--'}"
return subject, detail
if mode == "fortune":
field = context.get("five_phase_field") or {}
pillars = field.get("pillars") or {}
dominant = (field.get("balance") or [{}])[0]
subject = f"{display_date} 观气"
detail = (
f"{pillars.get('year') or '--'}年 · {pillars.get('month') or '--'}月 · "
f"{pillars.get('day') or '--'}日 · {dominant.get('element') or '--'}气偏显"
)
return subject, detail
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
return (
f"{display_date} 观心",
f"{hexagram.get('name') or '--'}{transformed.get('name') or '--'}",
)
def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]:
mode = str(payload.get("mode") or "").strip()
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("问天解读模式不正确。")
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
if mode == "fortune":
existing = self.database.latest_heaven_reading(
self.current_user_id, "fortune", trade_date
)
if existing:
return {
"answer": existing["answer"],
"mode": mode,
"compiler": "stored",
"notice": "",
"reading": existing,
"reused": True,
}
if mode in {"trend", "fortune"}:
setup = self.heaven_setup(
trade_date,
@@ -2351,17 +2412,41 @@ class DashboardService:
"five_phase_field": fortune_field,
"personal_profile": personal_profile,
}
context_date = setup["calendar_date"]
if mode == "trend":
context_date = setup["trade_date"]
else:
context = {
"hexagram": self.heaven_hexagram(payload.get("lines")),
"ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。",
}
context_date = trade_date
result, compiler = self._call_heaven_agent(mode, context)
subject, subject_detail = self._heaven_reading_identity(
mode, context_date, context
)
dedupe_key = (
f"fortune:{context_date}"
if mode == "fortune"
else f"{mode}:{context_date}:{secrets.token_urlsafe(12)}"
)
reading = self.database.save_heaven_reading(
self.current_user_id,
mode,
context_date,
subject,
subject_detail,
str(result.get("answer") or ""),
context,
dedupe_key,
)
return {
**result,
"mode": mode,
"compiler": compiler,
"notice": "智能解读已自动切换可用服务。" if compiler == "fallback" else "",
"reading": reading,
"reused": False,
}
def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]:
@@ -3704,6 +3789,19 @@ class RequestHandler(BaseHTTPRequestHandler):
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/heaven/readings":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.heaven_readings(
query.get("mode", [""])[0],
query.get("context_date", [""])[0],
int(query.get("limit", ["100"])[0]),
)
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/heaven/setup":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
@@ -3891,6 +3989,13 @@ class RequestHandler(BaseHTTPRequestHandler):
{"ok": True, **SERVICE.delete_trade_entry(int(trade_match.group(1)))}
)
return
heaven_reading_match = re.fullmatch(r"/api/heaven/readings/(\d+)", parsed.path)
if heaven_reading_match:
deleted = SERVICE.database.delete_heaven_reading(
SERVICE.current_user_id, int(heaven_reading_match.group(1))
)
self.send_json({"ok": True, "deleted": deleted})
return
sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path)
if sector_phase_match:
name = unquote(sector_phase_match.group(1)).strip()
+200 -18
View File
@@ -67,6 +67,12 @@ const state = {
personalField: null,
heavenPanel: "trend",
heavenInterpretations: { trend: "", fortune: "", heart: "" },
heavenReadingMode: "trend",
heavenReadingTab: "current",
heavenReadingHistory: { trend: [], fortune: [], heart: [] },
heavenReadingSelectedId: 0,
heavenReadingLoading: false,
heavenReadingError: "",
heartStage: "intro",
heartTimer: null,
heartSeconds: 30,
@@ -98,6 +104,7 @@ const elements = {
tradeLogDialog: document.querySelector("#tradeLogDialog"),
alertsDialog: document.querySelector("#alertsDialog"),
assistantDialog: document.querySelector("#assistantDialog"),
heavenReadingDialog: document.querySelector("#heavenReadingDialog"),
globalSearchDialog: document.querySelector("#globalSearchDialog"),
globalSearchInput: document.querySelector("#globalSearchInput"),
globalSearchResults: document.querySelector("#globalSearchResults"),
@@ -554,6 +561,8 @@ function bindEvents() {
});
document.querySelector("#interpretTrendButton").addEventListener("click", () => interpretHeaven("trend"));
document.querySelector("#interpretFortuneButton").addEventListener("click", () => interpretHeaven("fortune"));
document.querySelector("#historyTrendButton").addEventListener("click", () => openHeavenHistory("trend"));
document.querySelector("#historyFortuneButton").addEventListener("click", () => openHeavenHistory("fortune"));
document.querySelector("#qiObservationDate").addEventListener("change", () => {
state.personalField = null;
state.heavenManualData = null;
@@ -572,10 +581,18 @@ function bindEvents() {
document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing);
document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting);
document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound);
document.querySelector("#historyHeartButton").addEventListener("click", () => openHeavenHistory("heart"));
initializeHeartCoinHold();
initializeHeartLineInspection();
document.querySelector("#interpretHeartButton").addEventListener("click", () => interpretHeaven("heart"));
document.querySelector("#viewHeartReadingButton").addEventListener("click", () => openHeavenReading("heart"));
document.querySelector("#restartHeartButton").addEventListener("click", resetHeartRitual);
document.querySelector("#closeHeavenReadingDialog").addEventListener("click", () => elements.heavenReadingDialog.close());
document.querySelectorAll("[data-heaven-reading-tab]").forEach((button) => {
button.addEventListener("click", () => selectHeavenReadingTab(button.dataset.heavenReadingTab));
});
document.querySelector("#heavenReadingHistoryList").addEventListener("click", handleHeavenHistorySelection);
document.querySelector("#heavenReadingHistoryDetail").addEventListener("click", handleHeavenHistoryAction);
document.querySelectorAll("[data-heart-return]").forEach((button) => {
button.addEventListener("click", resetHeartRitual);
});
@@ -2225,6 +2242,7 @@ async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
payload.requestedDate = requestedDate;
payload.requestedKey = requestedKey;
state.heavenSetup = payload;
state.heavenInterpretations.fortune = payload.daily_fortune_reading || "";
state.heavenManualData = Object.keys(payload.chart?.manual_data || {}).length
? payload.chart.manual_data
: null;
@@ -3095,12 +3113,159 @@ function hexagramLineGraphic(value) {
`;
}
const HEAVEN_READING_META = {
trend: { panel: "观势", action: "解势", done: "查看解势", status: "势已成", loading: "正在合参卦势" },
fortune: { panel: "观气", action: "解运", done: "已解运", status: "气已定", loading: "正在推演气机" },
heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解", loading: "正在整理卦辞" },
};
function heavenReadingMeta(mode = state.heavenReadingMode) {
return HEAVEN_READING_META[mode] || HEAVEN_READING_META.trend;
}
function openHeavenReading(mode, options = {}) {
state.heavenReadingMode = mode;
state.heavenReadingTab = "current";
state.heavenReadingError = options.error || "";
state.heavenReadingLoading = Object.hasOwn(options, "loading")
? Boolean(options.loading)
: false;
renderHeavenReadingDialog();
if (!elements.heavenReadingDialog.open) elements.heavenReadingDialog.showModal();
requestAnimationFrame(() => document.querySelector("#closeHeavenReadingDialog").focus());
}
async function openHeavenHistory(mode) {
state.heavenReadingMode = mode;
state.heavenReadingTab = "history";
state.heavenReadingSelectedId = 0;
renderHeavenReadingDialog();
if (!elements.heavenReadingDialog.open) elements.heavenReadingDialog.showModal();
await loadHeavenReadingHistory(mode);
}
function selectHeavenReadingTab(tab) {
state.heavenReadingTab = tab === "history" ? "history" : "current";
renderHeavenReadingDialog();
if (state.heavenReadingTab === "history") loadHeavenReadingHistory(state.heavenReadingMode);
}
async function loadHeavenReadingHistory(mode) {
const list = document.querySelector("#heavenReadingHistoryList");
list.innerHTML = '<div class="empty-state">正在读取历史记录</div>';
try {
const query = new URLSearchParams({ mode, limit: "100" });
const payload = await apiRequest(`/api/heaven/readings?${query}`);
state.heavenReadingHistory[mode] = payload.items || [];
if (!state.heavenReadingHistory[mode].some((item) => number(item.id) === state.heavenReadingSelectedId)) {
state.heavenReadingSelectedId = number(state.heavenReadingHistory[mode][0]?.id);
}
renderHeavenReadingHistory();
} catch (error) {
list.innerHTML = `<div class="empty-state">${escapeHtml(error.message || "历史记录加载失败")}</div>`;
}
}
function renderHeavenReadingDialog() {
const meta = heavenReadingMeta();
setText("heavenReadingEyebrow", `问天 · ${meta.panel}`);
setText("heavenReadingDialogTitle", state.heavenReadingTab === "history" ? "历史记录" : meta.status);
document.querySelectorAll("[data-heaven-reading-tab]").forEach((button) => {
const active = button.dataset.heavenReadingTab === state.heavenReadingTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
document.querySelector("#heavenReadingCurrent").hidden = state.heavenReadingTab !== "current";
document.querySelector("#heavenReadingHistory").hidden = state.heavenReadingTab !== "history";
if (state.heavenReadingTab === "current") renderHeavenReadingCurrent();
else renderHeavenReadingHistory();
refreshIcons();
}
function renderHeavenReadingCurrent() {
const reading = state.heavenInterpretations[state.heavenReadingMode];
const loading = document.querySelector("#heavenReadingLoading");
const empty = document.querySelector("#heavenReadingEmpty");
const result = document.querySelector("#heavenReadingResult");
const error = document.querySelector("#heavenReadingError");
loading.hidden = !state.heavenReadingLoading;
setText("heavenReadingLoadingTitle", heavenReadingMeta().loading);
error.hidden = !state.heavenReadingError;
error.textContent = state.heavenReadingError;
result.hidden = state.heavenReadingLoading || !reading;
empty.hidden = state.heavenReadingLoading || Boolean(reading) || Boolean(state.heavenReadingError);
if (!reading || state.heavenReadingLoading) return;
setText("heavenReadingResultStatus", heavenReadingMeta().status);
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 || "");
}
function renderHeavenReadingHistory() {
const mode = state.heavenReadingMode;
const items = state.heavenReadingHistory[mode] || [];
setText("heavenReadingHistoryTitle", `${heavenReadingMeta(mode).panel}记录`);
setText("heavenReadingHistoryCount", `${items.length}`);
const list = document.querySelector("#heavenReadingHistoryList");
list.innerHTML = items.map((item) => `
<button class="heaven-reading-history-item ${number(item.id) === state.heavenReadingSelectedId ? "active" : ""}" type="button" data-heaven-reading-id="${number(item.id)}">
<span>${escapeHtml(item.subject)}</span>
<small>${escapeHtml(item.subject_detail || displayCompactDate(item.context_date))}</small>
<time>${formatTimestamp(item.created_at)}</time>
</button>
`).join("") || '<div class="empty-state">暂无历史解读</div>';
const selected = items.find((item) => number(item.id) === state.heavenReadingSelectedId);
const detail = document.querySelector("#heavenReadingHistoryDetail");
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>
<footer><button class="button" type="button" data-delete-heaven-reading="${number(selected.id)}"><i data-lucide="trash-2"></i><span></span></button></footer>
` : '<div class="empty-state">选择一条记录查看完整解读</div>';
refreshIcons();
}
function handleHeavenHistorySelection(event) {
const button = event.target.closest("[data-heaven-reading-id]");
if (!button) return;
state.heavenReadingSelectedId = number(button.dataset.heavenReadingId);
renderHeavenReadingHistory();
}
async function handleHeavenHistoryAction(event) {
const button = event.target.closest("[data-delete-heaven-reading]");
if (!button || !window.confirm("确定删除这条解读记录吗?")) return;
const id = number(button.dataset.deleteHeavenReading);
try {
await apiRequest(`/api/heaven/readings/${id}`, "DELETE");
const mode = state.heavenReadingMode;
state.heavenReadingHistory[mode] = (state.heavenReadingHistory[mode] || []).filter((item) => number(item.id) !== id);
if (number(state.heavenInterpretations[mode]?.id) === id) {
state.heavenInterpretations[mode] = "";
if (mode === "fortune" && state.heavenSetup) state.heavenSetup.daily_fortune_reading = null;
updateHeavenInterpretationControls();
}
state.heavenReadingSelectedId = number(state.heavenReadingHistory[mode][0]?.id);
renderHeavenReadingHistory();
} catch (error) {
showToast(error.message || "解读记录删除失败");
}
}
async function interpretHeaven(mode) {
const existing = state.heavenInterpretations[mode];
if (existing) {
openHeavenReading(mode, { loading: false });
return;
}
const button = document.querySelector(mode === "trend" ? "#interpretTrendButton" : mode === "fortune" ? "#interpretFortuneButton" : "#interpretHeartButton");
if (button.disabled) return;
button.disabled = true;
const originalText = button.textContent;
button.textContent = mode === "trend" ? "正在观势" : mode === "fortune" ? "正在察运" : "正在解卦";
state.heavenReadingMode = mode;
state.heavenReadingLoading = true;
state.heavenReadingError = "";
openHeavenReading(mode, { loading: true });
updateHeavenInterpretationControls();
hideHeavenNotice();
try {
const payload = {
@@ -3112,10 +3277,15 @@ 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);
state.heavenInterpretations[mode] = {
state.heavenInterpretations[mode] = result.reading || {
answer: result.answer,
meta: mode === "trend" ? "观势已成" : mode === "fortune" ? "观气已成" : "解卦已成",
subject: `${heavenReadingMeta(mode).panel}解读`,
context_date: payload.trade_date,
created_at: new Date().toISOString(),
};
state.heavenReadingHistory[mode] = [];
state.heavenReadingLoading = false;
renderHeavenReadingDialog();
if (result.notice) showHeavenNotice(result.notice);
if (mode === "heart") {
if (await transitionHeartStage("interpretation")) await playHeartReadSequence();
@@ -3123,23 +3293,33 @@ async function interpretHeaven(mode) {
renderHeavenInterpretation(mode, state.heavenInterpretations[mode]);
}
} catch (error) {
showHeavenNotice(error.message || "问天解读失败");
showToast(error.message || "问天解读失败");
state.heavenReadingLoading = false;
state.heavenReadingError = error.message || "问天解读失败";
renderHeavenReadingDialog();
showHeavenNotice(state.heavenReadingError);
showToast(state.heavenReadingError);
} finally {
button.disabled = false;
button.textContent = originalText;
state.heavenReadingLoading = false;
updateHeavenInterpretationControls();
}
}
function renderHeavenInterpretation(mode, result) {
const container = document.querySelector(`#${mode}Interpretation`);
if (!result) {
container.hidden = true;
container.innerHTML = "";
return;
}
container.hidden = false;
container.innerHTML = `<div>${formatMentorAnswer(result.answer)}</div><small>${escapeHtml(result.meta)}</small>`;
function updateHeavenInterpretationControls() {
const loading = state.heavenReadingLoading;
const trendButton = document.querySelector("#interpretTrendButton");
const fortuneButton = document.querySelector("#interpretFortuneButton");
const heartButton = document.querySelector("#interpretHeartButton");
trendButton.disabled = loading || !state.heavenSetup?.chart?.available;
fortuneButton.disabled = loading || !state.heavenSetup?.field;
heartButton.disabled = loading || state.heartLines.length !== 6;
trendButton.textContent = loading && state.heavenReadingMode === "trend" ? "正在观势" : state.heavenInterpretations.trend ? HEAVEN_READING_META.trend.done : HEAVEN_READING_META.trend.action;
fortuneButton.textContent = loading && state.heavenReadingMode === "fortune" ? "正在察运" : state.heavenInterpretations.fortune ? HEAVEN_READING_META.fortune.done : HEAVEN_READING_META.fortune.action;
heartButton.textContent = loading && state.heavenReadingMode === "heart" ? "正在解卦" : state.heavenInterpretations.heart ? HEAVEN_READING_META.heart.done : HEAVEN_READING_META.heart.action;
document.querySelector("#viewHeartReadingButton").disabled = !state.heavenInterpretations.heart;
}
function renderHeavenInterpretation() {
updateHeavenInterpretationControls();
}
function initializeHeartAtmosphere() {
@@ -3455,6 +3635,7 @@ async function finalizeHeartHexagram() {
const payload = await apiRequest("/api/heaven/hexagram", "POST", { lines: state.heartLines });
if (stageToken !== state.heartStageToken || state.heartStage !== "casting") return;
state.heartHexagram = payload.hexagram;
updateHeavenInterpretationControls();
document.querySelector(".heart-hexagram-shell")?.classList.add("is-complete");
setText("castingPrompt", "卦成了");
heartSound.chime(660);
@@ -3658,6 +3839,7 @@ async function resetHeartRitual() {
state.heartThrows = [];
state.heartHexagram = null;
state.heavenInterpretations.heart = "";
updateHeavenInterpretationControls();
state.heartRevealToken += 1;
heartCastingBusy = false;
resetHeartCoins();
+40 -4
View File
@@ -425,6 +425,7 @@
</div>
</div>
<button id="loadHeavenSelectionButton" class="button" type="button">载入</button>
<button id="historyTrendButton" class="button" type="button"><i data-lucide="history"></i><span>历史记录</span></button>
<button id="interpretTrendButton" class="button primary" type="button" disabled>解势</button>
</div>
<div id="heavenTrendEmpty" class="heaven-trend-empty" role="status">请输入股票代码或股票名称</div>
@@ -478,7 +479,6 @@
</div>
</form>
</section>
<section id="trendInterpretation" class="heaven-interpretation" hidden></section>
</section>
<section id="heavenFortunePanel" class="heaven-panel">
@@ -486,6 +486,7 @@
<div class="fortune-calendar-heading"><span id="fortuneLunarDate" class="metric-label">--</span><h3 id="fortunePillars">--</h3></div>
<div class="fortune-heading-actions">
<label class="qi-time-field"><span>观测日期</span><input id="qiObservationDate" type="date"></label>
<button id="historyFortuneButton" class="button" type="button"><i data-lucide="history"></i><span>历史记录</span></button>
<button id="interpretFortuneButton" class="button primary" type="button">解运</button>
</div>
</div>
@@ -518,7 +519,6 @@
<div><span><i data-lucide="shield-check"></i>风险与制衡</span><strong id="humanBalanceActions">--</strong></div>
</div>
</section>
<section id="fortuneInterpretation" class="heaven-interpretation fortune-interpretation" hidden></section>
<section class="personal-fortune-panel">
<div class="workspace-heading"><h3>个人影响</h3><span>日主、十神喜恶、五行喜忌与当日作用</span></div>
<div id="personalProfileEmpty" class="personal-profile-empty">
@@ -568,6 +568,9 @@
<button id="heartSoundToggle" class="heart-sound-toggle" type="button" aria-pressed="false" aria-label="开启观心声音">
<i data-lucide="volume-x"></i><span>静音</span>
</button>
<button id="historyHeartButton" class="heart-history-button" type="button" aria-label="查看观心历史记录">
<i data-lucide="history"></i><span>历史记录</span>
</button>
<div id="heartRitualCurtain" class="heart-ritual-curtain" aria-hidden="true">
<i class="heart-daybreak-dark"></i><strong>观 心</strong><span>心静,而后问</span>
</div>
@@ -649,14 +652,13 @@
<div id="heartInterpretationStage" class="heart-stage">
<div class="heart-interpretation-heading">
<div><span class="heart-stage-index">观心 · 五</span><h3 id="heartReadTitle">解卦</h3><small id="heartReadChange">--</small></div>
<button id="restartHeartButton" class="button" type="button">重新观心</button>
<div class="heart-read-actions"><button id="viewHeartReadingButton" class="button primary" type="button">查看解卦</button><button id="restartHeartButton" class="button" type="button">重新观心</button></div>
</div>
<p id="heartReadGuaci" class="heart-read-guaci">--</p>
<div class="heart-read-layout">
<div id="heartReadLines" class="heart-read-lines"></div>
<div id="heartReadTexts" class="heart-read-texts"></div>
</div>
<section id="heartInterpretation" class="heaven-interpretation heart-read-interpretation"></section>
<p class="heart-read-motto">一念既察,卦只是镜。</p>
</div>
<p class="heaven-footnote heart-footnote">观心用于观察念头与执着,不用于替代交易计划或预测涨跌。</p>
@@ -982,6 +984,40 @@
</form>
</dialog>
<dialog id="heavenReadingDialog" class="settings-dialog heaven-reading-dialog" aria-labelledby="heavenReadingDialogTitle">
<div class="dialog-header">
<div><span id="heavenReadingEyebrow" class="dialog-eyebrow">问天 · 观势</span><h2 id="heavenReadingDialogTitle">解读</h2></div>
<button id="closeHeavenReadingDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
<div class="heaven-reading-tabs" role="tablist" aria-label="问天解读">
<button class="active" type="button" role="tab" data-heaven-reading-tab="current" aria-selected="true">本次解读</button>
<button type="button" role="tab" data-heaven-reading-tab="history" aria-selected="false">历史记录</button>
</div>
<section id="heavenReadingCurrent" class="heaven-reading-current" role="tabpanel">
<div id="heavenReadingLoading" class="heaven-reading-loading" hidden>
<span class="heaven-reading-mark" aria-hidden="true"></span>
<strong id="heavenReadingLoadingTitle">正在合参诸象</strong>
<small>请稍候,结果生成后将在此呈现</small>
</div>
<div id="heavenReadingEmpty" class="empty-state">尚无本次解读</div>
<article id="heavenReadingResult" class="heaven-reading-result" hidden>
<header><div><span id="heavenReadingResultStatus">势已成</span><h3 id="heavenReadingSubject">--</h3></div><time id="heavenReadingCreatedAt">--</time></header>
<p id="heavenReadingSubjectDetail">--</p>
<div id="heavenReadingAnswer" class="heaven-reading-answer"></div>
</article>
<div id="heavenReadingError" class="inline-notice" hidden></div>
</section>
<section id="heavenReadingHistory" class="heaven-reading-history" role="tabpanel" hidden>
<div class="heaven-reading-history-list-wrap">
<div class="heaven-reading-history-heading"><strong id="heavenReadingHistoryTitle">观势记录</strong><span id="heavenReadingHistoryCount">0 条</span></div>
<div id="heavenReadingHistoryList" class="heaven-reading-history-list"></div>
</div>
<article id="heavenReadingHistoryDetail" class="heaven-reading-history-detail">
<div class="empty-state">选择一条记录查看完整解读</div>
</article>
</section>
</dialog>
<div id="stockPreviewBackdrop" class="stock-preview-backdrop" hidden></div>
<aside id="stockPreview" class="stock-preview" aria-label="个股行情预览" aria-live="polite" hidden>
<header class="stock-preview-header">
+112
View File
@@ -11604,3 +11604,115 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin
.assistant-stream-caret { animation: none; }
.assistant-messages { scroll-behavior: auto; }
}
.heart-history-button {
min-height: 44px;
position: absolute;
top: 12px;
right: 102px;
z-index: 8;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 12px;
border: 1px solid rgba(36, 40, 32, .18);
border-radius: 2px;
background: rgba(253, 252, 248, .92);
color: #62675f;
box-shadow: 0 4px 18px rgba(36, 40, 32, .06);
cursor: pointer;
font-family: var(--heaven-serif);
font-size: 11px;
}
.heart-history-button:hover,
.heart-history-button:focus-visible { border-color: rgba(154, 91, 69, .58); color: #824a39; outline: none; }
.heart-history-button .lucide { width: 15px; height: 15px; }
.heart-read-actions { display: flex; gap: 8px; }
.heaven-reading-dialog {
width: min(920px, calc(100vw - 32px));
max-height: min(820px, calc(100dvh - 32px));
overflow: hidden;
background: #fdfcf8;
}
.heaven-reading-tabs {
display: flex;
gap: 4px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
}
.heaven-reading-tabs button {
min-height: 34px;
padding: 0 12px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
font-size: 12px;
}
.heaven-reading-tabs button.active { border-bottom-color: #9a5b45; color: #6f3f31; font-weight: 700; }
.heaven-reading-tabs button:focus-visible { outline: 2px solid #9a5b45; outline-offset: 1px; }
.heaven-reading-current { min-height: 430px; max-height: calc(100dvh - 170px); overflow-y: auto; padding: 24px 28px 30px; }
.heaven-reading-loading { min-height: 360px; display: grid; place-content: center; justify-items: center; gap: 10px; text-align: center; }
.heaven-reading-loading[hidden] { display: none; }
.heaven-reading-mark {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border: 1px solid rgba(154, 91, 69, .35);
border-radius: 50%;
color: #8b503e;
font-family: var(--heaven-serif);
font-size: 24px;
animation: heaven-reading-breathe 2.4s ease-in-out infinite;
}
@keyframes heaven-reading-breathe { 50% { opacity: .55; box-shadow: 0 0 0 10px rgba(154, 91, 69, .05); } }
.heaven-reading-loading strong { margin-top: 8px; color: #343a35; font-family: var(--heaven-serif); font-size: 18px; letter-spacing: 0; }
.heaven-reading-loading small { color: var(--text-secondary); }
.heaven-reading-result > header,
.heaven-reading-history-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.heaven-reading-result > header span,
.heaven-reading-history-detail > header span { color: #9a5b45; font-family: var(--heaven-serif); font-size: 12px; font-weight: 700; }
.heaven-reading-result h3,
.heaven-reading-history-detail h3 { margin: 5px 0 0; color: #2d332e; font-family: var(--heaven-serif); font-size: 22px; font-weight: 600; letter-spacing: 0; }
.heaven-reading-result time,
.heaven-reading-history-detail time { color: var(--text-secondary); font-size: 11px; white-space: nowrap; }
.heaven-reading-result > p,
.heaven-reading-history-detail > p { margin: 12px 0 0; color: var(--text-secondary); font-size: 12px; }
.heaven-reading-answer { margin-top: 24px; color: #303833; font-family: var(--heaven-serif); font-size: 15px; line-height: 1.95; }
.heaven-reading-answer .mentor-answer-heading { color: #713f31; }
.heaven-reading-history { min-height: 500px; max-height: calc(100dvh - 170px); display: grid; grid-template-columns: 270px minmax(0, 1fr); overflow: hidden; }
.heaven-reading-history[hidden] { display: none; }
.heaven-reading-history-list-wrap { min-width: 0; overflow-y: auto; border-right: 1px solid var(--border); background: rgba(246, 244, 237, .72); }
.heaven-reading-history-heading { display: flex; justify-content: space-between; gap: 10px; padding: 15px 16px 10px; color: var(--text-secondary); font-size: 11px; }
.heaven-reading-history-heading strong { color: var(--text); font-size: 13px; }
.heaven-reading-history-list { display: grid; }
.heaven-reading-history-item { display: grid; gap: 4px; width: 100%; padding: 13px 16px; border: 0; border-top: 1px solid var(--border); background: transparent; color: var(--text); cursor: pointer; text-align: left; }
.heaven-reading-history-item:hover,
.heaven-reading-history-item.active { background: #fff; box-shadow: inset 3px 0 #9a5b45; }
.heaven-reading-history-item:focus-visible { outline: 2px solid #9a5b45; outline-offset: -2px; }
.heaven-reading-history-item span { overflow: hidden; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.heaven-reading-history-item small,
.heaven-reading-history-item time { overflow: hidden; color: var(--text-secondary); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.heaven-reading-history-detail { min-width: 0; overflow-y: auto; padding: 24px 28px; }
.heaven-reading-history-detail footer { display: flex; justify-content: flex-end; margin-top: 28px; padding-top: 14px; border-top: 1px solid var(--border); }
@media (max-width: 720px) {
.heart-history-button { top: 8px; right: 60px; min-width: 42px; padding: 0 10px; }
.heart-history-button span { display: none; }
.heart-read-actions { flex-wrap: wrap; justify-content: flex-end; }
.heaven-reading-dialog { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); margin: 8px auto; }
.heaven-reading-current { min-height: 360px; max-height: calc(100dvh - 150px); padding: 20px 17px 24px; }
.heaven-reading-history { min-height: 0; max-height: calc(100dvh - 150px); grid-template-columns: 1fr; overflow-y: auto; }
.heaven-reading-history-list-wrap { max-height: 210px; border-right: 0; border-bottom: 1px solid var(--border); }
.heaven-reading-history-detail { overflow: visible; padding: 20px 17px 24px; }
.heaven-reading-result > header,
.heaven-reading-history-detail > header { display: grid; }
}
@media (prefers-reduced-motion: reduce) {
.heaven-reading-mark { animation: none; }
}
+59
View File
@@ -144,6 +144,19 @@ async function mockApplication(page, authSession = session()) {
}],
summary: { total: 1, observed: 1, t1_win_rate: 100, t5_win_rate: null, average_t5: null },
};
} else if (url.pathname === "/api/heaven/readings") {
payload = {
mode: url.searchParams.get("mode") || "fortune",
items: [{
id: 31,
mode: "fortune",
context_date: "20260723",
subject: "2026-07-23 观气",
subject_detail: "丙午年 · 乙未月 · 己丑日 · 土气偏显",
answer: "三层气机已经合参,今日宜先定节奏,再看行动。",
created_at: "2026-07-23T09:12:00+08:00",
}],
};
} else if (url.pathname === "/api/mentors/setup") payload = { trade_date: "20260722", mentors: [] };
else if (url.pathname === "/api/heaven/setup") {
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "测试环境不加载问天数据" }) });
@@ -214,6 +227,37 @@ test("stock hover preview ignores the selected historical date", async ({ page }
await expect(page.locator("#stockPreviewName")).toHaveText("Test Stock");
});
test("saved daily fortune opens in the reading dialog without regenerating", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
await page.locator('[data-view="heavenView"]').first().click();
await page.locator('[data-heaven-panel="fortune"]').click();
await page.evaluate(() => {
state.heavenSetup = { field: {}, chart: { available: true } };
state.heavenInterpretations.fortune = {
id: 31,
mode: "fortune",
context_date: "20260723",
subject: "2026-07-23 观气",
subject_detail: "丙午年 · 乙未月 · 己丑日 · 土气偏显",
answer: "三层气机已经合参,今日宜先定节奏,再看行动。",
created_at: "2026-07-23T09:12:00+08:00",
};
updateHeavenInterpretationControls();
});
await expect(page.locator("#interpretFortuneButton")).toHaveText("已解运");
const interpretRequests = [];
page.on("request", (request) => {
if (request.url().includes("/api/heaven/interpret")) interpretRequests.push(request.url());
});
await page.locator("#interpretFortuneButton").click();
await expect(page.locator("#heavenReadingDialog")).toBeVisible();
await expect(page.locator("#heavenReadingAnswer")).toContainText("今日宜先定节奏");
expect(interpretRequests).toHaveLength(0);
await page.locator('[data-heaven-reading-tab="history"]').click();
await expect(page.locator("#heavenReadingHistoryList .heaven-reading-history-item")).toHaveCount(1);
});
test("mobile shell stays within the viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await mockApplication(page, session("user", true));
@@ -280,6 +324,21 @@ for (const viewport of [
const dialogWidth = await page.locator("#tradeLogDialog").evaluate((dialog) => dialog.getBoundingClientRect().width);
expect(dialogWidth).toBeLessThanOrEqual(viewport.width);
await page.locator("#closeTradeLogDialog").click();
await page.evaluate(() => {
state.heavenInterpretations.fortune = {
id: 31,
subject: "2026-07-23 观气",
subject_detail: "丙午年 · 乙未月 · 己丑日",
answer: "当日解运结果",
context_date: "20260723",
created_at: "2026-07-23T09:12:00+08:00",
};
openHeavenReading("fortune", { loading: false });
});
await expect(page.locator("#heavenReadingDialog")).toBeVisible();
const readingWidth = await page.locator("#heavenReadingDialog").evaluate((dialog) => dialog.getBoundingClientRect().width);
expect(readingWidth).toBeLessThanOrEqual(viewport.width);
await page.locator("#closeHeavenReadingDialog").click();
});
}
+2
View File
@@ -12,6 +12,7 @@ class ApiAccessPolicyTests(unittest.TestCase):
("GET", "/api/screener/tracking"): "member",
("GET", "/api/mentors/messages"): "member",
("GET", "/api/heaven/setup"): "member",
("GET", "/api/heaven/readings"): "member",
("GET", "/api/assistant/messages"): "member",
("POST", "/api/screener/run"): "member",
("POST", "/api/screener/tracking/refresh"): "member",
@@ -21,6 +22,7 @@ class ApiAccessPolicyTests(unittest.TestCase):
("DELETE", "/api/screener/strategies/42"): "member",
("DELETE", "/api/mentors/messages"): "member",
("DELETE", "/api/assistant/messages"): "member",
("DELETE", "/api/heaven/readings/42"): "member",
}
for (method, path), role in cases.items():
with self.subTest(method=method, path=path):
+9
View File
@@ -99,6 +99,15 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn('elements.tradeLogDialog.showModal()', self.script)
self.assertIn('renderTradeLog();\n closeTradeLogDialog();', self.script)
def test_heaven_interpretations_use_one_dialog_and_history_tabs(self):
self.assertIn('id="heavenReadingDialog"', self.html)
self.assertIn('data-heaven-reading-tab="current"', self.html)
self.assertIn('data-heaven-reading-tab="history"', self.html)
for button_id in ("historyTrendButton", "historyFortuneButton", "historyHeartButton"):
self.assertIn(f'id="{button_id}"', self.html)
self.assertIn('state.heavenInterpretations.fortune = payload.daily_fortune_reading || "";', self.script)
self.assertIn('if (existing) {\n openHeavenReading(mode, { loading: false });', self.script)
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
from database import ReviewDatabase
from server import DashboardService
class HeavenReadingTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
self.owner = self.database.create_user("heaven_owner", "salt", "hash")
self.other = self.database.create_user("heaven_other", "salt", "hash")
def tearDown(self) -> None:
self.temp.cleanup()
def save(self, user_id: int, dedupe_key: str = "fortune:20260723") -> dict:
return self.database.save_heaven_reading(
user_id,
"fortune",
"20260723",
"2026-07-23 观气",
"丙午年 · 乙未月 · 己丑日",
"当日解运结果",
{"calendar_date": "20260723"},
dedupe_key,
)
def test_history_is_private_and_delete_requires_ownership(self):
reading = self.save(self.owner["id"])
self.assertEqual(
self.database.list_heaven_readings(self.owner["id"], "fortune")[0]["answer"],
"当日解运结果",
)
self.assertEqual(
self.database.list_heaven_readings(self.other["id"], "fortune"), []
)
self.assertFalse(
self.database.delete_heaven_reading(self.other["id"], reading["id"])
)
self.assertTrue(
self.database.delete_heaven_reading(self.owner["id"], reading["id"])
)
def test_fortune_dedupe_keeps_the_first_successful_result(self):
first = self.save(self.owner["id"])
second = self.database.save_heaven_reading(
self.owner["id"],
"fortune",
"20260723",
"replacement",
"replacement",
"不应覆盖",
{},
"fortune:20260723",
)
self.assertEqual(second["id"], first["id"])
self.assertEqual(second["answer"], "当日解运结果")
def test_fortune_interpretation_reuses_saved_result_before_llm(self):
existing = self.save(self.owner["id"])
service = DashboardService.__new__(DashboardService)
service.database = self.database
service._request_context = threading.local()
service._request_context.user_id = self.owner["id"]
with patch.object(service, "heaven_setup") as setup, patch.object(
service, "_call_heaven_agent"
) as call_agent:
result = service.heaven_interpret(
{"mode": "fortune", "trade_date": "2026-07-23"}
)
setup.assert_not_called()
call_agent.assert_not_called()
self.assertTrue(result["reused"])
self.assertEqual(result["reading"]["id"], existing["id"])
self.assertEqual(result["answer"], "当日解运结果")
if __name__ == "__main__":
unittest.main()