diff --git a/api_access.py b/api_access.py index ad27193..2df7024 100644 --- a/api_access.py +++ b/api_access.py @@ -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" diff --git a/database.py b/database.py index b086046..f0368ca 100644 --- a/database.py +++ b/database.py @@ -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: diff --git a/server.py b/server.py index c3f9e41..1194281 100644 --- a/server.py +++ b/server.py @@ -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() diff --git a/static/app.js b/static/app.js index 17476a0..0614c7a 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = '
${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}
+--
一念既察,卦只是镜。
观心用于观察念头与执着,不用于替代交易计划或预测涨跌。
@@ -982,6 +984,40 @@ + +