feat: persist heaven readings in unified dialog
This commit is contained in:
@@ -13,6 +13,7 @@ MEMBER_GET_PATHS = frozenset(
|
|||||||
"/api/mentors/setup",
|
"/api/mentors/setup",
|
||||||
"/api/mentors/messages",
|
"/api/mentors/messages",
|
||||||
"/api/heaven/setup",
|
"/api/heaven/setup",
|
||||||
|
"/api/heaven/readings",
|
||||||
"/api/assistant/messages",
|
"/api/assistant/messages",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -60,4 +61,6 @@ def required_role(method: str, path: str) -> AccessRole:
|
|||||||
r"/api/screener/strategies/\d+", path
|
r"/api/screener/strategies/\d+", path
|
||||||
):
|
):
|
||||||
return "member"
|
return "member"
|
||||||
|
if re.fullmatch(r"/api/heaven/readings/\d+", path):
|
||||||
|
return "member"
|
||||||
return "authenticated"
|
return "authenticated"
|
||||||
|
|||||||
+121
@@ -334,6 +334,24 @@ class ReviewDatabase:
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_assistant_messages_user
|
CREATE INDEX IF NOT EXISTS idx_assistant_messages_user
|
||||||
ON assistant_messages(user_id, id DESC);
|
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 = {
|
user_columns = {
|
||||||
@@ -1720,6 +1738,109 @@ class ReviewDatabase:
|
|||||||
)
|
)
|
||||||
return int(cursor.rowcount)
|
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:
|
def start_sync(self, trade_date: str, source: str) -> int:
|
||||||
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
|
|||||||
@@ -2023,6 +2023,9 @@ class DashboardService:
|
|||||||
"chart": chart,
|
"chart": chart,
|
||||||
"field": field,
|
"field": field,
|
||||||
"personal_profile": personal_profile,
|
"personal_profile": personal_profile,
|
||||||
|
"daily_fortune_reading": self.database.latest_heaven_reading(
|
||||||
|
self.current_user_id, "fortune", normalized_date
|
||||||
|
),
|
||||||
"sector_phase_overrides": [
|
"sector_phase_overrides": [
|
||||||
{"name": name, "element": element}
|
{"name": name, "element": element}
|
||||||
for name, element in sector_phase_overrides.items()
|
for name, element in sector_phase_overrides.items()
|
||||||
@@ -2286,11 +2289,69 @@ class DashboardService:
|
|||||||
raise ValueError("六爻必须由六、七、八、九组成。") from exc
|
raise ValueError("六爻必须由六、七、八、九组成。") from exc
|
||||||
return hexagram_from_lines(lines)
|
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]:
|
def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
mode = str(payload.get("mode") or "").strip()
|
mode = str(payload.get("mode") or "").strip()
|
||||||
if mode not in {"trend", "fortune", "heart"}:
|
if mode not in {"trend", "fortune", "heart"}:
|
||||||
raise ValueError("问天解读模式不正确。")
|
raise ValueError("问天解读模式不正确。")
|
||||||
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
|
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"}:
|
if mode in {"trend", "fortune"}:
|
||||||
setup = self.heaven_setup(
|
setup = self.heaven_setup(
|
||||||
trade_date,
|
trade_date,
|
||||||
@@ -2351,17 +2412,41 @@ class DashboardService:
|
|||||||
"five_phase_field": fortune_field,
|
"five_phase_field": fortune_field,
|
||||||
"personal_profile": personal_profile,
|
"personal_profile": personal_profile,
|
||||||
}
|
}
|
||||||
|
context_date = setup["calendar_date"]
|
||||||
|
if mode == "trend":
|
||||||
|
context_date = setup["trade_date"]
|
||||||
else:
|
else:
|
||||||
context = {
|
context = {
|
||||||
"hexagram": self.heaven_hexagram(payload.get("lines")),
|
"hexagram": self.heaven_hexagram(payload.get("lines")),
|
||||||
"ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。",
|
"ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。",
|
||||||
}
|
}
|
||||||
|
context_date = trade_date
|
||||||
result, compiler = self._call_heaven_agent(mode, context)
|
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 {
|
return {
|
||||||
**result,
|
**result,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"compiler": compiler,
|
"compiler": compiler,
|
||||||
"notice": "智能解读已自动切换可用服务。" if compiler == "fallback" else "",
|
"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]:
|
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:
|
except ValueError as exc:
|
||||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
return
|
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":
|
if parsed.path == "/api/heaven/setup":
|
||||||
query = parse_qs(parsed.query)
|
query = parse_qs(parsed.query)
|
||||||
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
|
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)))}
|
{"ok": True, **SERVICE.delete_trade_entry(int(trade_match.group(1)))}
|
||||||
)
|
)
|
||||||
return
|
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)
|
sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path)
|
||||||
if sector_phase_match:
|
if sector_phase_match:
|
||||||
name = unquote(sector_phase_match.group(1)).strip()
|
name = unquote(sector_phase_match.group(1)).strip()
|
||||||
|
|||||||
+200
-18
@@ -67,6 +67,12 @@ const state = {
|
|||||||
personalField: null,
|
personalField: null,
|
||||||
heavenPanel: "trend",
|
heavenPanel: "trend",
|
||||||
heavenInterpretations: { trend: "", fortune: "", heart: "" },
|
heavenInterpretations: { trend: "", fortune: "", heart: "" },
|
||||||
|
heavenReadingMode: "trend",
|
||||||
|
heavenReadingTab: "current",
|
||||||
|
heavenReadingHistory: { trend: [], fortune: [], heart: [] },
|
||||||
|
heavenReadingSelectedId: 0,
|
||||||
|
heavenReadingLoading: false,
|
||||||
|
heavenReadingError: "",
|
||||||
heartStage: "intro",
|
heartStage: "intro",
|
||||||
heartTimer: null,
|
heartTimer: null,
|
||||||
heartSeconds: 30,
|
heartSeconds: 30,
|
||||||
@@ -98,6 +104,7 @@ const elements = {
|
|||||||
tradeLogDialog: document.querySelector("#tradeLogDialog"),
|
tradeLogDialog: document.querySelector("#tradeLogDialog"),
|
||||||
alertsDialog: document.querySelector("#alertsDialog"),
|
alertsDialog: document.querySelector("#alertsDialog"),
|
||||||
assistantDialog: document.querySelector("#assistantDialog"),
|
assistantDialog: document.querySelector("#assistantDialog"),
|
||||||
|
heavenReadingDialog: document.querySelector("#heavenReadingDialog"),
|
||||||
globalSearchDialog: document.querySelector("#globalSearchDialog"),
|
globalSearchDialog: document.querySelector("#globalSearchDialog"),
|
||||||
globalSearchInput: document.querySelector("#globalSearchInput"),
|
globalSearchInput: document.querySelector("#globalSearchInput"),
|
||||||
globalSearchResults: document.querySelector("#globalSearchResults"),
|
globalSearchResults: document.querySelector("#globalSearchResults"),
|
||||||
@@ -554,6 +561,8 @@ function bindEvents() {
|
|||||||
});
|
});
|
||||||
document.querySelector("#interpretTrendButton").addEventListener("click", () => interpretHeaven("trend"));
|
document.querySelector("#interpretTrendButton").addEventListener("click", () => interpretHeaven("trend"));
|
||||||
document.querySelector("#interpretFortuneButton").addEventListener("click", () => interpretHeaven("fortune"));
|
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", () => {
|
document.querySelector("#qiObservationDate").addEventListener("change", () => {
|
||||||
state.personalField = null;
|
state.personalField = null;
|
||||||
state.heavenManualData = null;
|
state.heavenManualData = null;
|
||||||
@@ -572,10 +581,18 @@ function bindEvents() {
|
|||||||
document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing);
|
document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing);
|
||||||
document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting);
|
document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting);
|
||||||
document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound);
|
document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound);
|
||||||
|
document.querySelector("#historyHeartButton").addEventListener("click", () => openHeavenHistory("heart"));
|
||||||
initializeHeartCoinHold();
|
initializeHeartCoinHold();
|
||||||
initializeHeartLineInspection();
|
initializeHeartLineInspection();
|
||||||
document.querySelector("#interpretHeartButton").addEventListener("click", () => interpretHeaven("heart"));
|
document.querySelector("#interpretHeartButton").addEventListener("click", () => interpretHeaven("heart"));
|
||||||
|
document.querySelector("#viewHeartReadingButton").addEventListener("click", () => openHeavenReading("heart"));
|
||||||
document.querySelector("#restartHeartButton").addEventListener("click", resetHeartRitual);
|
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) => {
|
document.querySelectorAll("[data-heart-return]").forEach((button) => {
|
||||||
button.addEventListener("click", resetHeartRitual);
|
button.addEventListener("click", resetHeartRitual);
|
||||||
});
|
});
|
||||||
@@ -2225,6 +2242,7 @@ async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
|
|||||||
payload.requestedDate = requestedDate;
|
payload.requestedDate = requestedDate;
|
||||||
payload.requestedKey = requestedKey;
|
payload.requestedKey = requestedKey;
|
||||||
state.heavenSetup = payload;
|
state.heavenSetup = payload;
|
||||||
|
state.heavenInterpretations.fortune = payload.daily_fortune_reading || "";
|
||||||
state.heavenManualData = Object.keys(payload.chart?.manual_data || {}).length
|
state.heavenManualData = Object.keys(payload.chart?.manual_data || {}).length
|
||||||
? payload.chart.manual_data
|
? payload.chart.manual_data
|
||||||
: null;
|
: 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) {
|
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");
|
const button = document.querySelector(mode === "trend" ? "#interpretTrendButton" : mode === "fortune" ? "#interpretFortuneButton" : "#interpretHeartButton");
|
||||||
if (button.disabled) return;
|
if (button.disabled) return;
|
||||||
button.disabled = true;
|
state.heavenReadingMode = mode;
|
||||||
const originalText = button.textContent;
|
state.heavenReadingLoading = true;
|
||||||
button.textContent = mode === "trend" ? "正在观势" : mode === "fortune" ? "正在察运" : "正在解卦";
|
state.heavenReadingError = "";
|
||||||
|
openHeavenReading(mode, { loading: true });
|
||||||
|
updateHeavenInterpretationControls();
|
||||||
hideHeavenNotice();
|
hideHeavenNotice();
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -3112,10 +3277,15 @@ async function interpretHeaven(mode) {
|
|||||||
if (mode === "trend" && state.heavenManualData) payload.manual_data = state.heavenManualData;
|
if (mode === "trend" && state.heavenManualData) payload.manual_data = state.heavenManualData;
|
||||||
if (mode === "heart") payload.lines = state.heartLines;
|
if (mode === "heart") payload.lines = state.heartLines;
|
||||||
const result = await apiRequest("/api/heaven/interpret", "POST", payload);
|
const result = await apiRequest("/api/heaven/interpret", "POST", payload);
|
||||||
state.heavenInterpretations[mode] = {
|
state.heavenInterpretations[mode] = result.reading || {
|
||||||
answer: result.answer,
|
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 (result.notice) showHeavenNotice(result.notice);
|
||||||
if (mode === "heart") {
|
if (mode === "heart") {
|
||||||
if (await transitionHeartStage("interpretation")) await playHeartReadSequence();
|
if (await transitionHeartStage("interpretation")) await playHeartReadSequence();
|
||||||
@@ -3123,23 +3293,33 @@ async function interpretHeaven(mode) {
|
|||||||
renderHeavenInterpretation(mode, state.heavenInterpretations[mode]);
|
renderHeavenInterpretation(mode, state.heavenInterpretations[mode]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showHeavenNotice(error.message || "问天解读失败");
|
state.heavenReadingLoading = false;
|
||||||
showToast(error.message || "问天解读失败");
|
state.heavenReadingError = error.message || "问天解读失败";
|
||||||
|
renderHeavenReadingDialog();
|
||||||
|
showHeavenNotice(state.heavenReadingError);
|
||||||
|
showToast(state.heavenReadingError);
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
state.heavenReadingLoading = false;
|
||||||
button.textContent = originalText;
|
updateHeavenInterpretationControls();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHeavenInterpretation(mode, result) {
|
function updateHeavenInterpretationControls() {
|
||||||
const container = document.querySelector(`#${mode}Interpretation`);
|
const loading = state.heavenReadingLoading;
|
||||||
if (!result) {
|
const trendButton = document.querySelector("#interpretTrendButton");
|
||||||
container.hidden = true;
|
const fortuneButton = document.querySelector("#interpretFortuneButton");
|
||||||
container.innerHTML = "";
|
const heartButton = document.querySelector("#interpretHeartButton");
|
||||||
return;
|
trendButton.disabled = loading || !state.heavenSetup?.chart?.available;
|
||||||
}
|
fortuneButton.disabled = loading || !state.heavenSetup?.field;
|
||||||
container.hidden = false;
|
heartButton.disabled = loading || state.heartLines.length !== 6;
|
||||||
container.innerHTML = `<div>${formatMentorAnswer(result.answer)}</div><small>${escapeHtml(result.meta)}</small>`;
|
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() {
|
function initializeHeartAtmosphere() {
|
||||||
@@ -3455,6 +3635,7 @@ async function finalizeHeartHexagram() {
|
|||||||
const payload = await apiRequest("/api/heaven/hexagram", "POST", { lines: state.heartLines });
|
const payload = await apiRequest("/api/heaven/hexagram", "POST", { lines: state.heartLines });
|
||||||
if (stageToken !== state.heartStageToken || state.heartStage !== "casting") return;
|
if (stageToken !== state.heartStageToken || state.heartStage !== "casting") return;
|
||||||
state.heartHexagram = payload.hexagram;
|
state.heartHexagram = payload.hexagram;
|
||||||
|
updateHeavenInterpretationControls();
|
||||||
document.querySelector(".heart-hexagram-shell")?.classList.add("is-complete");
|
document.querySelector(".heart-hexagram-shell")?.classList.add("is-complete");
|
||||||
setText("castingPrompt", "卦成了");
|
setText("castingPrompt", "卦成了");
|
||||||
heartSound.chime(660);
|
heartSound.chime(660);
|
||||||
@@ -3658,6 +3839,7 @@ async function resetHeartRitual() {
|
|||||||
state.heartThrows = [];
|
state.heartThrows = [];
|
||||||
state.heartHexagram = null;
|
state.heartHexagram = null;
|
||||||
state.heavenInterpretations.heart = "";
|
state.heavenInterpretations.heart = "";
|
||||||
|
updateHeavenInterpretationControls();
|
||||||
state.heartRevealToken += 1;
|
state.heartRevealToken += 1;
|
||||||
heartCastingBusy = false;
|
heartCastingBusy = false;
|
||||||
resetHeartCoins();
|
resetHeartCoins();
|
||||||
|
|||||||
+40
-4
@@ -425,6 +425,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="loadHeavenSelectionButton" class="button" type="button">载入</button>
|
<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>
|
<button id="interpretTrendButton" class="button primary" type="button" disabled>解势</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="heavenTrendEmpty" class="heaven-trend-empty" role="status">请输入股票代码或股票名称</div>
|
<div id="heavenTrendEmpty" class="heaven-trend-empty" role="status">请输入股票代码或股票名称</div>
|
||||||
@@ -478,7 +479,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section id="trendInterpretation" class="heaven-interpretation" hidden></section>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="heavenFortunePanel" class="heaven-panel">
|
<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-calendar-heading"><span id="fortuneLunarDate" class="metric-label">--</span><h3 id="fortunePillars">--</h3></div>
|
||||||
<div class="fortune-heading-actions">
|
<div class="fortune-heading-actions">
|
||||||
<label class="qi-time-field"><span>观测日期</span><input id="qiObservationDate" type="date"></label>
|
<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>
|
<button id="interpretFortuneButton" class="button primary" type="button">解运</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -518,7 +519,6 @@
|
|||||||
<div><span><i data-lucide="shield-check"></i>风险与制衡</span><strong id="humanBalanceActions">--</strong></div>
|
<div><span><i data-lucide="shield-check"></i>风险与制衡</span><strong id="humanBalanceActions">--</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="fortuneInterpretation" class="heaven-interpretation fortune-interpretation" hidden></section>
|
|
||||||
<section class="personal-fortune-panel">
|
<section class="personal-fortune-panel">
|
||||||
<div class="workspace-heading"><h3>个人影响</h3><span>日主、十神喜恶、五行喜忌与当日作用</span></div>
|
<div class="workspace-heading"><h3>个人影响</h3><span>日主、十神喜恶、五行喜忌与当日作用</span></div>
|
||||||
<div id="personalProfileEmpty" class="personal-profile-empty">
|
<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="开启观心声音">
|
<button id="heartSoundToggle" class="heart-sound-toggle" type="button" aria-pressed="false" aria-label="开启观心声音">
|
||||||
<i data-lucide="volume-x"></i><span>静音</span>
|
<i data-lucide="volume-x"></i><span>静音</span>
|
||||||
</button>
|
</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">
|
<div id="heartRitualCurtain" class="heart-ritual-curtain" aria-hidden="true">
|
||||||
<i class="heart-daybreak-dark"></i><strong>观 心</strong><span>心静,而后问</span>
|
<i class="heart-daybreak-dark"></i><strong>观 心</strong><span>心静,而后问</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -649,14 +652,13 @@
|
|||||||
<div id="heartInterpretationStage" class="heart-stage">
|
<div id="heartInterpretationStage" class="heart-stage">
|
||||||
<div class="heart-interpretation-heading">
|
<div class="heart-interpretation-heading">
|
||||||
<div><span class="heart-stage-index">观心 · 五</span><h3 id="heartReadTitle">解卦</h3><small id="heartReadChange">--</small></div>
|
<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>
|
</div>
|
||||||
<p id="heartReadGuaci" class="heart-read-guaci">--</p>
|
<p id="heartReadGuaci" class="heart-read-guaci">--</p>
|
||||||
<div class="heart-read-layout">
|
<div class="heart-read-layout">
|
||||||
<div id="heartReadLines" class="heart-read-lines"></div>
|
<div id="heartReadLines" class="heart-read-lines"></div>
|
||||||
<div id="heartReadTexts" class="heart-read-texts"></div>
|
<div id="heartReadTexts" class="heart-read-texts"></div>
|
||||||
</div>
|
</div>
|
||||||
<section id="heartInterpretation" class="heaven-interpretation heart-read-interpretation"></section>
|
|
||||||
<p class="heart-read-motto">一念既察,卦只是镜。</p>
|
<p class="heart-read-motto">一念既察,卦只是镜。</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="heaven-footnote heart-footnote">观心用于观察念头与执着,不用于替代交易计划或预测涨跌。</p>
|
<p class="heaven-footnote heart-footnote">观心用于观察念头与执着,不用于替代交易计划或预测涨跌。</p>
|
||||||
@@ -982,6 +984,40 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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>
|
<div id="stockPreviewBackdrop" class="stock-preview-backdrop" hidden></div>
|
||||||
<aside id="stockPreview" class="stock-preview" aria-label="个股行情预览" aria-live="polite" hidden>
|
<aside id="stockPreview" class="stock-preview" aria-label="个股行情预览" aria-live="polite" hidden>
|
||||||
<header class="stock-preview-header">
|
<header class="stock-preview-header">
|
||||||
|
|||||||
@@ -11604,3 +11604,115 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin
|
|||||||
.assistant-stream-caret { animation: none; }
|
.assistant-stream-caret { animation: none; }
|
||||||
.assistant-messages { scroll-behavior: auto; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 },
|
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/mentors/setup") payload = { trade_date: "20260722", mentors: [] };
|
||||||
else if (url.pathname === "/api/heaven/setup") {
|
else if (url.pathname === "/api/heaven/setup") {
|
||||||
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "测试环境不加载问天数据" }) });
|
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");
|
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 }) => {
|
test("mobile shell stays within the viewport", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 375, height: 812 });
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
await mockApplication(page, session("user", true));
|
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);
|
const dialogWidth = await page.locator("#tradeLogDialog").evaluate((dialog) => dialog.getBoundingClientRect().width);
|
||||||
expect(dialogWidth).toBeLessThanOrEqual(viewport.width);
|
expect(dialogWidth).toBeLessThanOrEqual(viewport.width);
|
||||||
await page.locator("#closeTradeLogDialog").click();
|
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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class ApiAccessPolicyTests(unittest.TestCase):
|
|||||||
("GET", "/api/screener/tracking"): "member",
|
("GET", "/api/screener/tracking"): "member",
|
||||||
("GET", "/api/mentors/messages"): "member",
|
("GET", "/api/mentors/messages"): "member",
|
||||||
("GET", "/api/heaven/setup"): "member",
|
("GET", "/api/heaven/setup"): "member",
|
||||||
|
("GET", "/api/heaven/readings"): "member",
|
||||||
("GET", "/api/assistant/messages"): "member",
|
("GET", "/api/assistant/messages"): "member",
|
||||||
("POST", "/api/screener/run"): "member",
|
("POST", "/api/screener/run"): "member",
|
||||||
("POST", "/api/screener/tracking/refresh"): "member",
|
("POST", "/api/screener/tracking/refresh"): "member",
|
||||||
@@ -21,6 +22,7 @@ class ApiAccessPolicyTests(unittest.TestCase):
|
|||||||
("DELETE", "/api/screener/strategies/42"): "member",
|
("DELETE", "/api/screener/strategies/42"): "member",
|
||||||
("DELETE", "/api/mentors/messages"): "member",
|
("DELETE", "/api/mentors/messages"): "member",
|
||||||
("DELETE", "/api/assistant/messages"): "member",
|
("DELETE", "/api/assistant/messages"): "member",
|
||||||
|
("DELETE", "/api/heaven/readings/42"): "member",
|
||||||
}
|
}
|
||||||
for (method, path), role in cases.items():
|
for (method, path), role in cases.items():
|
||||||
with self.subTest(method=method, path=path):
|
with self.subTest(method=method, path=path):
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ class FrontendContractTests(unittest.TestCase):
|
|||||||
self.assertIn('elements.tradeLogDialog.showModal()', self.script)
|
self.assertIn('elements.tradeLogDialog.showModal()', self.script)
|
||||||
self.assertIn('renderTradeLog();\n closeTradeLogDialog();', 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user