feat: restore full-fidelity heaven rituals

This commit is contained in:
leefer
2026-07-23 23:50:12 +08:00
parent bc7521a591
commit eb1e2bca9a
8 changed files with 211 additions and 207 deletions
+1 -13
View File
@@ -60,24 +60,12 @@ def interpret_heaven(
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc: except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc
return { return {
"answer": _limit_answer(mode, answer), "answer": answer,
"model": model, "model": model,
"latency_ms": round((time.perf_counter() - started) * 1000), "latency_ms": round((time.perf_counter() - started) * 1000),
} }
def _limit_answer(mode: str, answer: str) -> str:
limits = {"trend": 520, "fortune": 680, "heart": 380}
limit = limits[mode]
if len(answer) <= limit:
return answer
window = answer[:limit]
cut = max(window.rfind(mark) for mark in ("", "", "", "\n"))
if cut < int(limit * 0.65):
cut = limit
return window[:cut + 1].rstrip() + "……"
def _system_prompt(mode: str) -> str: def _system_prompt(mode: str) -> str:
common = """ common = """
你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情。 你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情。
+15 -3
View File
@@ -2082,6 +2082,11 @@ class DashboardService:
field, field,
public=True, public=True,
) )
daily_fortune_reading = self.database.latest_heaven_reading(
self.current_user_id, "fortune", normalized_date
)
if self._legacy_truncated_heaven_reading(daily_fortune_reading):
daily_fortune_reading = None
return { return {
"trade_date": data_date, "trade_date": data_date,
"calendar_date": normalized_date, "calendar_date": normalized_date,
@@ -2089,9 +2094,7 @@ 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( "daily_fortune_reading": daily_fortune_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()
@@ -2409,6 +2412,11 @@ class DashboardService:
existing = self.database.latest_heaven_reading( existing = self.database.latest_heaven_reading(
self.current_user_id, "fortune", trade_date self.current_user_id, "fortune", trade_date
) )
if self._legacy_truncated_heaven_reading(existing):
self.database.delete_heaven_reading(
self.current_user_id, int(existing["id"])
)
existing = None
if existing: if existing:
return { return {
"answer": existing["answer"], "answer": existing["answer"],
@@ -2515,6 +2523,10 @@ class DashboardService:
"reused": False, "reused": False,
} }
@staticmethod
def _legacy_truncated_heaven_reading(reading: dict[str, Any] | None) -> bool:
return bool(reading and str(reading.get("answer") or "").rstrip().endswith("……"))
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]:
source = self.ensure_llm_access(f"heaven_{mode}") source = self.ensure_llm_access(f"heaven_{mode}")
primary_error = "" primary_error = ""
+3 -4
View File
@@ -3476,9 +3476,9 @@ function hexagramLineGraphic(value) {
} }
const HEAVEN_READING_META = { const HEAVEN_READING_META = {
trend: { panel: "观势", action: "解势", done: "查看解势", status: "势已成", loading: "正在合参卦势" }, trend: { panel: "观势", action: "解势", done: "查看解势", status: "势已成" },
fortune: { panel: "观气", action: "解运", done: "已解运", status: "气已定", loading: "正在推演气机" }, fortune: { panel: "观气", action: "解运", done: "已解运", status: "气已定" },
heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解", loading: "正在整理卦辞" }, heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解" },
}; };
function heavenReadingMeta(mode = state.heavenReadingMode) { function heavenReadingMeta(mode = state.heavenReadingMode) {
@@ -3596,7 +3596,6 @@ function renderHeavenReadingCurrent() {
const result = document.querySelector("#heavenReadingResult"); const result = document.querySelector("#heavenReadingResult");
const error = document.querySelector("#heavenReadingError"); const error = document.querySelector("#heavenReadingError");
loading.hidden = !state.heavenReadingLoading; loading.hidden = !state.heavenReadingLoading;
setText("heavenReadingLoadingTitle", heavenReadingMeta().loading);
syncHeavenReadingAnimation(); syncHeavenReadingAnimation();
error.hidden = !state.heavenReadingError; error.hidden = !state.heavenReadingError;
error.textContent = state.heavenReadingError; error.textContent = state.heavenReadingError;
+148 -161
View File
@@ -1,21 +1,20 @@
(function exposeHeavenLoading(global) { (function exposeHeavenLoading(global) {
"use strict"; "use strict";
const PAPER = "#fdfcf8"; const DARK_INK = "#0b0e1e";
const PAPER_CENTER = "#f4eee2"; const INK = "#e6c37a";
const INK = "#76513d"; const INK_BRIGHT = "#f7e3b4";
const INK_BRIGHT = "#914f3d"; const GOLD = "#e6c37a";
const GOLD = "#a97d43"; const GOLD_BRIGHT = "#f7e3b4";
const GOLD_BRIGHT = "#c69a58"; const CINNABAR = "#d8564a";
const CINNABAR = "#b64e43"; const DIM = "rgba(216,205,180,0.55)";
const DIM = "rgba(75, 72, 63, 0.58)";
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif'; const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
const ELEMENT_COLORS = { const ELEMENT_COLORS = {
: "#648b5b", : "#7fb069",
: "#b85043", : "#d8564a",
: "#a88038", : "#d9a441",
: "#8b8171", : "#e6dec4",
: "#587a9f", : "#5b84b8",
}; };
const QI6 = [ const QI6 = [
{ name: "厥阴风木", element: "木" }, { name: "厥阴风木", element: "木" },
@@ -25,6 +24,7 @@
{ name: "阳明燥金", element: "金" }, { name: "阳明燥金", element: "金" },
{ name: "太阳寒水", element: "水" }, { name: "太阳寒水", element: "水" },
]; ];
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
const TRIGRAMS = [ const TRIGRAMS = [
{ name: "乾", bits: [1, 1, 1], angle: -90 }, { name: "乾", bits: [1, 1, 1], angle: -90 },
{ name: "兑", bits: [1, 1, 0], angle: -135 }, { name: "兑", bits: [1, 1, 0], angle: -135 },
@@ -47,21 +47,6 @@
"复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临", "复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临",
"损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾", "损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾",
]; ];
const HEX_STAGES = [
[0, 1800, "太 极", "无极而太极,动而生阳"],
[1800, 3300, "两 仪", "一阴一阳之谓道"],
[3300, 4700, "四 象", "阴阳消长,太少相生"],
[4700, 6800, "八 卦", "天地定位,山泽通气"],
[6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"],
];
const FORTUNE_STAGES = [
[0, 2100, "五 运", "木火土金水,五运相袭,周而复始"],
[2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"],
[3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明"],
[5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"],
[7900, 11000, "岁 运 合 参", "五运承岁,六气应时"],
];
const clamp01 = (value) => Math.max(0, Math.min(1, value)); const clamp01 = (value) => Math.max(0, Math.min(1, value));
const smooth = (start, end, value) => { const smooth = (start, end, value) => {
const progress = clamp01((value - start) / Math.max(1, end - start)); const progress = clamp01((value - start) / Math.max(1, end - start));
@@ -91,7 +76,7 @@
this.completionTimer = 0; this.completionTimer = 0;
this.resizeObserver = new ResizeObserver(() => this.resize()); this.resizeObserver = new ResizeObserver(() => this.resize());
this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches; this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches;
this.stars = this.createStars(this.reducedMotion ? 48 : 110); this.stars = this.createStars(this.reducedMotion ? 48 : 150);
} }
createStars(count) { createStars(count) {
@@ -103,9 +88,9 @@
return Array.from({ length: count }, () => ({ return Array.from({ length: count }, () => ({
x: random(), x: random(),
y: random(), y: random(),
radius: 0.25 + random() * 1.05, radius: 0.3 + random() * 1.3,
phase: random() * Math.PI * 2, phase: random() * Math.PI * 2,
speed: 0.00002 + random() * 0.00005, speed: 0.00015 + random() * 0.0004,
})); }));
} }
@@ -139,7 +124,7 @@
if (this.completionResolve) return this.completionPromise; if (this.completionResolve) return this.completionPromise;
this.completingAt = performance.now(); this.completingAt = performance.now();
this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; }); this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; });
this.completionTimer = global.setTimeout(() => this.stop(), 900); this.completionTimer = global.setTimeout(() => this.stop(), 2200);
return this.completionPromise; return this.completionPromise;
} }
@@ -176,7 +161,8 @@
frame(now) { frame(now) {
if (!this.running) return; if (!this.running) return;
if (this.completingAt) { if (this.completingAt) {
const progress = clamp01((now - this.completingAt) / 620); const duration = this.scene === "fortune" ? 1800 : 1700;
const progress = clamp01((now - this.completingAt) / duration);
this.drawCompletion(progress, now); this.drawCompletion(progress, now);
if (progress >= 1) { if (progress >= 1) {
this.stop(); this.stop();
@@ -199,15 +185,16 @@
drawBackground(now) { drawBackground(now) {
const { context: ctx, width, height } = this; const { context: ctx, width, height } = this;
const cx = width / 2; const cx = width / 2;
const cy = height * 0.43; const cy = height * 0.44;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75); const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
gradient.addColorStop(0, PAPER_CENTER); gradient.addColorStop(0, "#10142a");
gradient.addColorStop(0.58, "#faf7f0"); gradient.addColorStop(0.45, "#0b0e1e");
gradient.addColorStop(1, PAPER); gradient.addColorStop(1, "#05060d");
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
for (const star of this.stars) { for (const star of this.stars) {
const alpha = 0.08 + 0.13 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.001)); const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
const alpha = twinkle * 0.5;
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
ctx.fillStyle = GOLD; ctx.fillStyle = GOLD;
const y = ((star.y + now * star.speed) % 1) * height; const y = ((star.y + now * star.speed) % 1) * height;
@@ -256,6 +243,28 @@
ctx.restore(); ctx.restore();
} }
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
if (alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(mx, my, x2, y2);
ctx.stroke();
const angle = Math.atan2(y2 - my, x2 - mx);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
ctx.closePath();
ctx.fill();
ctx.restore();
}
drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) { drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) {
const ctx = this.context; const ctx = this.context;
ctx.save(); ctx.save();
@@ -295,29 +304,30 @@
ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
const breath = 1 + 0.005 * Math.sin(now / 620); const breath = 1 + 0.006 * Math.sin(now / 620);
TRIGRAMS.forEach((trigram, index) => { TRIGRAMS.forEach((trigram, index) => {
const progress = entering ? easeOut((time - 4700 - index * 120) / 650) : 1; const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1;
if (progress <= 0) return; if (progress <= 0) return;
const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle); const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle);
this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 7); this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8);
this.label(trigram.name, x, y + lineWidth * 5.2, 11, INK, alpha * progress * 0.68); const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
}); });
} }
drawHexagram(time, now) { drawHexagram(time, now) {
const { width, height } = this; const { width, height } = this;
const cx = width / 2; const cx = width / 2;
const cy = height * 0.41; const cy = height * 0.44;
const scale = Math.min(width, height); const scale = Math.min(width, height);
if (time < 1800) { if (time < 1800) {
const alpha = this.stageAlpha(time, 0, 1800); const alpha = this.stageAlpha(time, 0, 1800);
this.node(cx, cy, 5.5 * (1 + 0.1 * Math.sin(now / 260)), INK_BRIGHT, alpha, 24); this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
for (let ring = 0; ring < 3; ring += 1) { for (let ring = 0; ring < 3; ring += 1) {
const progress = ((now / 1500) + ring / 3) % 1; const progress = ((now / 1500) + ring / 3) % 1;
const ctx = this.context; const ctx = this.context;
ctx.save(); ctx.save();
ctx.globalAlpha = (1 - progress) * 0.18 * alpha; ctx.globalAlpha = (1 - progress) * 0.22 * alpha;
ctx.strokeStyle = GOLD; ctx.strokeStyle = GOLD;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2); ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2);
@@ -329,92 +339,105 @@
const alpha = this.stageAlpha(time, 1800, 3300); const alpha = this.stageAlpha(time, 1800, 3300);
const progress = easeOut((time - 1850) / 850); const progress = easeOut((time - 1850) / 850);
const yaoWidth = scale * 0.19 * progress; const yaoWidth = scale * 0.19 * progress;
const yaoLine = Math.max(scale * 0.013, 4); const yaoLine = Math.max(scale * 0.013, 5);
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 10); this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 10); this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
} }
if (time >= 3300 && time < 4700) { if (time >= 3300 && time < 4700) {
const alpha = this.stageAlpha(time, 3300, 4700); const alpha = this.stageAlpha(time, 3300, 4700);
const distance = scale * 0.085; const distance = scale * 0.085;
const yaoWidth = Math.max(scale * 0.055, 26); const yaoWidth = Math.max(scale * 0.055, 28);
const yaoLine = Math.max(scale * 0.008, 3); const yaoLine = Math.max(scale * 0.009, 3.5);
SIXIANG.forEach((symbol, index) => { SIXIANG.forEach((symbol, index) => {
const progress = easeOut((time - 3330 - index * 150) / 520); const progress = easeOut((time - 3330 - index * 160) / 520);
if (progress <= 0) return; if (progress <= 0) return;
const x = cx + symbol.dx * distance; const x = cx + symbol.dx * distance;
const y = cy + symbol.dy * distance; const y = cy + symbol.dy * distance;
this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 7); this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10);
this.label(symbol.name, x, y + yaoLine * 5.3, 10, INK, alpha * progress * 0.7); this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
}); });
} }
const trigramRadius = scale * 0.205; const trigramRadius = scale * 0.215;
const trigramWidth = Math.max(scale * 0.05, 24); const trigramWidth = Math.max(scale * 0.052, 26);
const trigramLine = Math.max(scale * 0.0068, 2.6); const trigramLine = Math.max(scale * 0.0075, 3);
if (time >= 4700 && time < 6800) { if (time >= 4700 && time < 6800) {
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time); this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
} }
if (time >= 6800) { if (time >= 6800) {
const alpha = this.stageAlpha(time, 6800, 10800, 350, true); const alpha = this.stageAlpha(time, 6800, 10800, 350, true);
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.82, trigramLine * 0.82, alpha * 0.46, now, false, time); this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time);
const ringRadius = scale * 0.34; const ringRadius = scale * 0.365;
const hexWidth = Math.max(scale * 0.024, 11); const hexWidth = Math.max(scale * 0.026, 13);
const hexLine = Math.max(scale * 0.0038, 1.35); const hexLine = Math.max(scale * 0.0042, 1.6);
const count = Math.floor(clamp01((time - 7000) / 3600) * 64); const count = Math.floor(clamp01((time - 7000) / 3600) * 64);
for (let index = 0; index < 64; index += 1) { for (let index = 0; index < 64; index += 1) {
const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64); const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64);
this.node(x, y, 1.1, GOLD, alpha * 0.14); this.node(x, y, 1.4, GOLD, alpha * 0.14);
if (index < count) { if (index < count) {
const freshness = Math.max(0, 1 - (count - 1 - index) / 5); const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
if (freshness > 0) this.line(cx, cy, x, y, GOLD, alpha * freshness * 0.2); if (freshness > 0) {
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.38 * freshness), freshness * 6); const ctx = this.context;
const gradient = ctx.createLinearGradient(cx, cy, x, y);
gradient.addColorStop(0, "rgba(230,195,122,0)");
gradient.addColorStop(1, GOLD);
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
}
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
} }
} }
if (count > 0) { if (count > 0) {
const current = count - 1; const current = count - 1;
this.drawGua(cx, cy - scale * 0.025, scale * 0.078, Math.max(scale * 0.0095, 4), hexBits(current), alpha, 11); const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.058, Math.max(18, scale * 0.038), INK_BRIGHT, alpha, "600"); const pop = 1 + 0.22 * (1 - popTime);
this.label(`${current + 1}`, cx, cy + scale * 0.098, 11, DIM, alpha * 0.85); this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
this.label(`${current + 1}`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55);
} }
} }
this.drawChrome(time, now, "易 经 · 两 仪 裂 变 推 衍", "易", HEX_STAGES, 10800, "hexagram");
} }
drawFortune(time, now) { drawFortune(time, now) {
const { width, height } = this; const { width, height } = this;
const cx = width / 2; const cx = width / 2;
const cy = height * 0.41; const cy = height * 0.44;
const scale = Math.min(width, height); const scale = Math.min(width, height);
if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale); if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale);
if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale); if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale);
if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale); if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale);
if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale); if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale);
if (time >= 7900) this.drawAnnualQi(time, now, cx, cy, scale); if (time >= 7900) this.drawAnnualQi(time, now, cx, cy, scale);
this.drawChrome(time, now, "黄 帝 内 经 · 五 运 六 气", "气", FORTUNE_STAGES, 11000, "fortune");
} }
drawFiveMovements(time, now, cx, cy, scale) { drawFiveMovements(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 0, 2100); const alpha = this.stageAlpha(time, 0, 2100);
const radius = scale * 0.17; const radius = scale * 0.17;
const nodeRadius = Math.max(scale * 0.017, 8); const nodeRadius = Math.max(scale * 0.018, 9);
const elements = [ const elements = [
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null], ["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
]; ];
const positions = {}; const positions = {};
this.node(cx, cy, 5 + Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 20); this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
elements.forEach(([element, degrees], index) => { elements.forEach(([element, degrees], index) => {
const progress = easeOut((time - 500 - index * 170) / 500); const progress = easeOut((time - 500 - index * 170) / 500);
if (progress <= 0) return; if (progress <= 0) return;
const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress; const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress;
const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress; const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress;
positions[element] = [x, y]; positions[element] = [x, y];
this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 10); this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16);
this.label(element, x, y, Math.max(10, nodeRadius * 1.1), PAPER, alpha * progress, "600"); this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), DARK_INK, alpha * progress, "600");
const direction = element === "土" ? "中央土" : { : "东方木", : "南方火", : "西方金", : "北方水" }[element];
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
}); });
const order = ["木", "火", "土", "金", "水"]; const order = ["木", "火", "土", "金", "水"];
order.forEach((element, index) => { order.forEach((element, index) => {
const from = positions[element]; const from = positions[element];
const to = positions[order[(index + 1) % order.length]]; const to = positions[order[(index + 1) % order.length]];
if (from && to) this.line(from[0], from[1], to[0], to[1], GOLD, alpha * smooth(1450 + index * 120, 1750 + index * 120, time) * 0.32); if (!from || !to) return;
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
}); });
} }
@@ -422,21 +445,23 @@
const alpha = this.stageAlpha(time, 2100, 3900); const alpha = this.stageAlpha(time, 2100, 3900);
const stems = "甲乙丙丁戊己庚辛壬癸"; const stems = "甲乙丙丁戊己庚辛壬癸";
const movements = ["土", "金", "水", "木", "火"]; const movements = ["土", "金", "水", "木", "火"];
const radius = scale * 0.29; const radius = scale * 0.30;
for (let index = 0; index < 10; index += 1) { for (let index = 0; index < 10; index += 1) {
const progress = smooth(2150 + index * 85, 2450 + index * 85, time); const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
if (progress <= 0) continue; if (progress <= 0) continue;
const [x, y] = point(cx, cy, radius, -90 + index * 36); const [x, y] = point(cx, cy, radius, -90 + index * 36);
const element = movements[index % 5]; const element = movements[index % 5];
this.node(x, y, 2.8, ELEMENT_COLORS[element], alpha * progress, 5); this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
this.label(stems[index], x, y - 13, 13, ELEMENT_COLORS[element], alpha * progress, "600"); this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
} }
for (let index = 0; index < 5; index += 1) { for (let index = 0; index < 5; index += 1) {
const progress = smooth(3100 + index * 100, 3400 + index * 100, time); const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
const [x1, y1] = point(cx, cy, radius, -90 + index * 36); const angle = -90 + index * 36;
const [x1, y1] = point(cx, cy, radius, angle);
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36); const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.38); this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
this.label(movements[index], (x1 + x2) / 2, (y1 + y2) / 2, 14, ELEMENT_COLORS[movements[index]], alpha * progress, "600"); const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
this.label(movements[index], labelX, labelY, 16, ELEMENT_COLORS[movements[index]], alpha * progress, "600");
} }
} }
@@ -444,29 +469,29 @@
const alpha = this.stageAlpha(time, 3900, 5800); const alpha = this.stageAlpha(time, 3900, 5800);
const branches = "子丑寅卯辰巳午未申酉戌亥"; const branches = "子丑寅卯辰巳午未申酉戌亥";
const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"]; const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"];
const radius = scale * 0.29; const radius = scale * 0.31;
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30; const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
for (let index = 0; index < 12; index += 1) { for (let index = 0; index < 12; index += 1) {
const progress = smooth(3950 + index * 65, 4220 + index * 65, time); const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
const [x, y] = point(cx, cy, radius, branchAngle(index)); const [x, y] = point(cx, cy, radius, branchAngle(index));
this.node(x, y, 2.3, GOLD, alpha * progress, 4); this.node(x, y, 2.5, GOLD, alpha * progress, 6);
this.label(branches[index], x, y - 12, 12, INK, alpha * progress); this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
} }
qiNames.forEach((name, index) => { qiNames.forEach((name, index) => {
const progress = smooth(4850 + index * 120, 5150 + index * 120, time); const progress = smooth(4900 + index * 130, 5200 + index * 130, time);
const [x1, y1] = point(cx, cy, radius, branchAngle(index)); const [x1, y1] = point(cx, cy, radius, branchAngle(index));
const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6)); const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6));
const element = QI6.find((item) => item.name === name)?.element || "土"; const element = QI6.find((item) => item.name === name)?.element || "土";
this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.34); this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4);
const [labelX, labelY] = point(cx, cy, radius + scale * 0.05, branchAngle(index)); const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
this.label(name, labelX, labelY, 10.5, ELEMENT_COLORS[element], alpha * progress, "600"); this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
}); });
} }
drawSixQi(time, now, cx, cy, scale) { drawSixQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 5800, 7900); const alpha = this.stageAlpha(time, 5800, 7900);
const radius = scale * 0.255; const radius = scale * 0.27;
const drift = now * 0.0015; const drift = now * 0.004;
const ctx = this.context; const ctx = this.context;
ctx.save(); ctx.save();
ctx.globalAlpha = alpha * 0.13; ctx.globalAlpha = alpha * 0.13;
@@ -476,14 +501,14 @@
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
QI6.forEach((qi, index) => { QI6.forEach((qi, index) => {
const progress = easeOut((time - 5850 - index * 170) / 540); const progress = easeOut((time - 5850 - index * 180) / 550);
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift); const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
const nodeRadius = Math.max(scale * 0.014, 7) * progress; const nodeRadius = Math.max(scale * 0.015, 8) * progress;
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 9); this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
this.label(qi.name, x, y - nodeRadius - 11, 11, ELEMENT_COLORS[qi.element], alpha * progress, "600"); this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 11, 9, DIM, alpha * progress); this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
}); });
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 16); this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
} }
drawAnnualQi(time, now, cx, cy, scale) { drawAnnualQi(time, now, cx, cy, scale) {
@@ -494,98 +519,60 @@
const sitian = sixQi.sitian || "司天气候"; const sitian = sixQi.sitian || "司天气候";
const zaiquan = sixQi.zaiquan || "在泉气化"; const zaiquan = sixQi.zaiquan || "在泉气化";
const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1)); const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1));
this.label("司 天", cx, cy - scale * 0.205, 10, DIM, alpha * smooth(7950, 8400, time)); const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
this.label(sitian, cx, cy - scale * 0.172, 15, INK, alpha * smooth(7950, 8400, time), "600"); const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
this.label(zaiquan, cx, cy + scale * 0.172, 15, INK, alpha * smooth(8200, 8650, time), "600"); this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
this.label("在 泉", cx, cy + scale * 0.205, 10, DIM, alpha * smooth(8200, 8650, time)); this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
this.label(pillar, cx, cy - scale * 0.01, Math.max(21, scale * 0.047), INK_BRIGHT, alpha * smooth(8500, 9050, time), "600"); this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
this.label(movement, cx, cy + scale * 0.05, 12, GOLD, alpha * smooth(8500, 9050, time), "600", scale * 0.48); this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
const radius = scale * 0.285; this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
const radius = scale * 0.30;
QI6.forEach((qi, index) => { QI6.forEach((qi, index) => {
const progress = smooth(9150 + index * 240, 9440 + index * 240, time); const progress = smooth(9200 + index * 260, 9480 + index * 260, time);
const [x, y] = point(cx, cy, radius, -90 + index * 60); const [x, y] = point(cx, cy, radius, -90 + index * 60);
const current = index + 1 === currentStep; const current = index + 1 === currentStep;
const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0; const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0;
this.node(x, y, Math.max(scale * 0.01, 5.5) + (current ? 2 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 7 + pulse * 10); this.node(x, y, Math.max(scale * 0.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
if (current) { if (current) {
const ctx = this.context; const ctx = this.context;
ctx.save(); ctx.save();
ctx.globalAlpha = alpha * (0.28 + pulse * 0.3); ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
ctx.strokeStyle = CINNABAR; ctx.strokeStyle = CINNABAR;
ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.beginPath();
ctx.arc(x, y, Math.max(scale * 0.019, 10) + pulse * 2, 0, Math.PI * 2); ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
this.label("当今", x, y - Math.max(scale * 0.035, 19), 9, CINNABAR, alpha * progress, "600"); this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
} }
this.label(`${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`, x, y + Math.max(scale * 0.029, 16), 9.5, current ? INK_BRIGHT : DIM, alpha * progress, current ? "600" : ""); const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
}); });
} }
drawChrome(time, now, title, seal, stages, holdAt, scene) {
const { width, height, context: ctx } = this;
this.label(title, width / 2, 26, width < 520 ? 11 : 13, INK, 0.58, "600");
ctx.save();
ctx.translate(width - (width < 520 ? 38 : 48), 14);
ctx.globalAlpha = 0.88;
ctx.fillStyle = CINNABAR;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(0, 0, 28, 28, 4);
else ctx.rect(0, 0, 28, 28);
ctx.fill();
ctx.restore();
this.label(seal, width - (width < 520 ? 24 : 34), 28, 17, "#fffaf0", 1, "600");
const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0];
const stageProgress = smooth(stage[0], stage[0] + 280, time);
this.label(stage[2], width / 2, height - 82, width < 520 ? 15 : 18, INK_BRIGHT, 0.55 + stageProgress * 0.4, "600");
this.label(stage[3], width / 2, height - 60, width < 520 ? 9.5 : 11.5, DIM, 0.72, "", width - 48);
const slotWidth = Math.min(30, (width - 96) / 9);
const slotGap = Math.min(10, slotWidth * 0.36);
const totalWidth = slotWidth * 6 + slotGap * 5;
const filled = Math.min(6, Math.floor(clamp01(time / holdAt) * 6));
for (let index = 0; index < 6; index += 1) {
const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap);
const y = height - 34;
ctx.save();
ctx.globalAlpha = 0.2;
ctx.strokeStyle = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
ctx.strokeRect(x, y, slotWidth, 4);
if (index < filled) {
ctx.globalAlpha = 0.78;
ctx.fillStyle = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
ctx.fillRect(x, y, slotWidth, 4);
} else if (index === filled && time < holdAt) {
ctx.globalAlpha = 0.38 + 0.18 * Math.sin(now / 220);
ctx.fillStyle = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
ctx.fillRect(x, y, slotWidth * ((time % (holdAt / 6)) / (holdAt / 6)), 4);
}
ctx.restore();
}
}
drawCompletion(progress, now) { drawCompletion(progress, now) {
this.drawBackground(now); this.drawBackground(now);
const { width, height } = this; const { width, height } = this;
const cx = width / 2; const cx = width / 2;
const cy = height * 0.41; const cy = height * 0.44;
const scale = Math.min(width, height); const scale = Math.min(width, height);
const eased = easeOut(progress); const eased = easeOut(progress);
if (this.scene === "fortune") { if (this.scene === "fortune") {
const radius = scale * 0.285 * (1 - eased); const radius = scale * 0.30 * (1 - eased);
QI6.forEach((qi, index) => { QI6.forEach((qi, index) => {
const [x, y] = point(cx, cy, radius, -90 + index * 60); const [x, y] = point(cx, cy, radius, -90 + index * 60);
if (radius > 5) this.node(x, y, Math.max(scale * 0.01, 5), ELEMENT_COLORS[qi.element], (1 - eased) * 0.78, 6); if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - eased) * 0.8, 8);
}); });
this.drawChrome(11000, now, "黄 帝 内 经 · 五 运 六 气", "气", [[0, 1, "归 一", "谨守病机,无失气宜"]], 11000, "fortune");
} else { } else {
const radius = scale * 0.34 * (1 - eased); const radius = scale * 0.365 * (1 - eased);
for (let index = 0; index < 64 && radius > 5; index += 1) { for (let index = 0; index < 64 && radius > 8; index += 1) {
const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64); const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64);
this.drawGua(x, y, Math.max(scale * 0.023, 10), Math.max(scale * 0.0036, 1.3), hexBits(index), (1 - eased) * 0.68); this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - eased) * 0.7);
} }
this.drawChrome(10800, now, "易 经 · 两 仪 裂 变 推 衍", "易", [[0, 1, "归 一", "万物负阴而抱阳,冲气以为和"]], 10800, "hexagram");
} }
this.node(cx, cy, 3 + eased * 6, INK_BRIGHT, 0.35 + eased * 0.65, 12 + eased * 28); this.node(cx, cy, 3 + eased * 6, GOLD_BRIGHT, 0.3 + eased * 0.7, 12 + eased * 40);
} }
} }
-4
View File
@@ -1025,10 +1025,6 @@
<section id="heavenReadingCurrent" class="heaven-reading-current" role="tabpanel"> <section id="heavenReadingCurrent" class="heaven-reading-current" role="tabpanel">
<div id="heavenReadingLoading" class="heaven-reading-loading" hidden> <div id="heavenReadingLoading" class="heaven-reading-loading" hidden>
<canvas id="heavenReadingCanvas" aria-hidden="true"></canvas> <canvas id="heavenReadingCanvas" aria-hidden="true"></canvas>
<div class="heaven-reading-loading-status" role="status" aria-live="polite">
<strong id="heavenReadingLoadingTitle">正在合参诸象</strong>
<small>诸象渐次归位</small>
</div>
</div> </div>
<div id="heavenReadingEmpty" class="empty-state">尚无本次解读</div> <div id="heavenReadingEmpty" class="empty-state">尚无本次解读</div>
<article id="heavenReadingResult" class="heaven-reading-result" hidden> <article id="heavenReadingResult" class="heaven-reading-result" hidden>
+1 -21
View File
@@ -11825,7 +11825,7 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin
min-height: 360px; min-height: 360px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: #fdfcf8; background: #05060d;
} }
.heaven-reading-loading[hidden] { display: none; } .heaven-reading-loading[hidden] { display: none; }
.heaven-reading-loading canvas { .heaven-reading-loading canvas {
@@ -11833,21 +11833,6 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin
height: 100%; height: 100%;
display: block; display: block;
} }
.heaven-reading-loading-status {
position: absolute;
right: 20px;
bottom: 8px;
left: 20px;
display: grid;
justify-items: center;
gap: 2px;
padding: 7px 10px 5px;
background: linear-gradient(180deg, rgba(253, 252, 248, 0), rgba(253, 252, 248, .9) 34%);
text-align: center;
pointer-events: none;
}
.heaven-reading-loading-status strong { color: #69493a; font-family: var(--heaven-serif); font-size: 12px; letter-spacing: 0; }
.heaven-reading-loading-status small { color: var(--text-secondary); font-family: var(--heaven-serif); font-size: 10px; }
.heaven-reading-result > header, .heaven-reading-result > header,
.heaven-reading-history-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } .heaven-reading-history-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.heaven-reading-result > header span, .heaven-reading-result > header span,
@@ -11893,11 +11878,6 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin
@media (max-height: 520px) { @media (max-height: 520px) {
.heaven-reading-loading { min-height: 260px; } .heaven-reading-loading { min-height: 260px; }
.heaven-reading-loading-status small { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.heaven-reading-loading-status { transition: none; }
} }
/* Mentor directory: dense evidence-aware navigation for larger skill libraries. */ /* Mentor directory: dense evidence-aware navigation for larger skill libraries. */
+2 -1
View File
@@ -374,7 +374,8 @@ test("heaven reading loading uses the matching canvas scene and stops cleanly",
await heavenReadingAnimation.complete(); await heavenReadingAnimation.complete();
return performance.now() - started; return performance.now() - started;
}); });
expect(completionMs).toBeLessThan(1000); expect(completionMs).toBeGreaterThanOrEqual(1600);
expect(completionMs).toBeLessThan(2000);
await expect(canvas).toHaveAttribute("data-running", "false"); await expect(canvas).toHaveAttribute("data-running", "false");
} }
await page.locator("#closeHeavenReadingDialog").click(); await page.locator("#closeHeavenReadingDialog").click();
+41
View File
@@ -83,6 +83,47 @@ class HeavenReadingTests(unittest.TestCase):
self.assertEqual(result["reading"]["id"], existing["id"]) self.assertEqual(result["reading"]["id"], existing["id"])
self.assertEqual(result["answer"], "当日解运结果") self.assertEqual(result["answer"], "当日解运结果")
def test_legacy_truncated_fortune_is_regenerated_once(self):
self.database.save_heaven_reading(
self.owner["id"],
"fortune",
"20260723",
"2026-07-23 观气",
"丙午年 · 乙未月 · 己丑日",
"旧版解运结果被截断……",
{"calendar_date": "20260723"},
"fortune:20260723",
)
service = DashboardService.__new__(DashboardService)
service.database = self.database
service._request_context = threading.local()
service._request_context.user_id = self.owner["id"]
setup = {
"calendar_date": "20260723",
"field": {
"sector_catalog": [],
"balance": [{"element": ""}],
"pillars": {"year": "丙午", "month": "乙未", "day": "己丑"},
},
}
with patch.object(service, "heaven_setup", return_value=setup), patch.object(
service, "account_personal_field", return_value={}
), patch.object(
service,
"_call_heaven_agent",
return_value=({"answer": "完整的当日解运结果", "model": "test", "latency_ms": 1}, "primary"),
):
result = service.heaven_interpret(
{"mode": "fortune", "trade_date": "2026-07-23"}
)
self.assertFalse(result["reused"])
self.assertEqual(result["reading"]["answer"], "完整的当日解运结果")
self.assertEqual(
len(self.database.list_heaven_readings(self.owner["id"], "fortune")), 1
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()