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/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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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