rebuild(stage-11): deliver deterministic heaven workflows
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="/heaven-loading.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
(function exposeHeavenLoading(global) {
|
||||
"use strict";
|
||||
|
||||
// Theme palettes share the original animation geometry and timing.
|
||||
const LOADING_PALETTES = {
|
||||
dark: {
|
||||
paper: "#05060d",
|
||||
paperCenter: "#10142a",
|
||||
paperMiddle: "#0b0e1e",
|
||||
nodeText: "#f7e3b4",
|
||||
ink: "#e6c37a",
|
||||
inkBright: "#f7e3b4",
|
||||
gold: "#e6c37a",
|
||||
goldBright: "#f7e3b4",
|
||||
cinnabar: "#d8564a",
|
||||
dim: "rgba(216,205,180,0.55)",
|
||||
particles: ["#e6c37a", "#d8564a", "#6d7fa8"],
|
||||
},
|
||||
light: {
|
||||
paper: "#eef1f4",
|
||||
paperCenter: "#fffefa",
|
||||
paperMiddle: "#f4f2eb",
|
||||
nodeText: "#493a20",
|
||||
ink: "#8a641d",
|
||||
inkBright: "#624612",
|
||||
gold: "#946b1d",
|
||||
goldBright: "#765315",
|
||||
cinnabar: "#b94f46",
|
||||
dim: "rgba(52,58,67,0.62)",
|
||||
particles: ["#946b1d", "#b94f46", "#73859c"],
|
||||
},
|
||||
};
|
||||
let PAPER;
|
||||
let PAPER_CENTER;
|
||||
let PAPER_MIDDLE;
|
||||
let NODE_TEXT;
|
||||
let INK;
|
||||
let INK_BRIGHT;
|
||||
let GOLD;
|
||||
let GOLD_BRIGHT;
|
||||
let CINNABAR;
|
||||
let DIM;
|
||||
let PARTICLE_COLORS;
|
||||
const applyLoadingPalette = () => {
|
||||
const theme = document.documentElement.dataset.theme === "light" ? "light" : "dark";
|
||||
const palette = LOADING_PALETTES[theme];
|
||||
PAPER = palette.paper;
|
||||
PAPER_CENTER = palette.paperCenter;
|
||||
PAPER_MIDDLE = palette.paperMiddle;
|
||||
NODE_TEXT = palette.nodeText;
|
||||
INK = palette.ink;
|
||||
INK_BRIGHT = palette.inkBright;
|
||||
GOLD = palette.gold;
|
||||
GOLD_BRIGHT = palette.goldBright;
|
||||
CINNABAR = palette.cinnabar;
|
||||
DIM = palette.dim;
|
||||
PARTICLE_COLORS = palette.particles;
|
||||
return theme;
|
||||
};
|
||||
applyLoadingPalette();
|
||||
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
|
||||
const ELEMENT_COLORS = {
|
||||
木: "#4f7a4a",
|
||||
火: "#b3483d",
|
||||
土: "#96702c",
|
||||
金: "#70685b",
|
||||
水: "#496d92",
|
||||
};
|
||||
const QI6 = [
|
||||
{ name: "厥阴风木", element: "木" },
|
||||
{ name: "少阴君火", element: "火" },
|
||||
{ name: "少阳相火", element: "火" },
|
||||
{ name: "太阴湿土", element: "土" },
|
||||
{ name: "阳明燥金", element: "金" },
|
||||
{ name: "太阳寒水", element: "水" },
|
||||
];
|
||||
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
|
||||
const TRIGRAMS = [
|
||||
{ name: "乾", bits: [1, 1, 1], angle: -90 },
|
||||
{ name: "兑", bits: [1, 1, 0], angle: -135 },
|
||||
{ name: "离", bits: [1, 0, 1], angle: 180 },
|
||||
{ name: "震", bits: [1, 0, 0], angle: 135 },
|
||||
{ name: "巽", bits: [0, 1, 1], angle: -45 },
|
||||
{ name: "坎", bits: [0, 1, 0], angle: 0 },
|
||||
{ name: "艮", bits: [0, 0, 1], angle: 45 },
|
||||
{ name: "坤", bits: [0, 0, 0], angle: 90 },
|
||||
];
|
||||
const SIXIANG = [
|
||||
{ name: "太阳", bits: [1, 1], dx: 0, dy: -1 },
|
||||
{ name: "少阴", bits: [1, 0], dx: 1, dy: 0 },
|
||||
{ name: "太阴", bits: [0, 0], dx: 0, dy: 1 },
|
||||
{ name: "少阳", bits: [0, 1], dx: -1, dy: 0 },
|
||||
];
|
||||
const HEXAGRAM_NAMES = [
|
||||
"坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁",
|
||||
"师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤",
|
||||
"复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临",
|
||||
"损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾",
|
||||
];
|
||||
const HEX_TOTAL = 12500;
|
||||
const FORTUNE_TOTAL = 12800;
|
||||
const HEX_STAGES = [
|
||||
[0, 1800, "太 极", "无极而太极,动而生阳"],
|
||||
[1800, 3300, "两 仪", "一阴一阳之谓道"],
|
||||
[3300, 4700, "四 象", "阴阳消长,太少相生"],
|
||||
[4700, 6800, "八 卦", "天地定位,山泽通气"],
|
||||
[6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"],
|
||||
[10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"],
|
||||
];
|
||||
const clamp01 = (value) => Math.max(0, Math.min(1, value));
|
||||
const smooth = (start, end, value) => {
|
||||
const progress = clamp01((value - start) / Math.max(1, end - start));
|
||||
return progress * progress * (3 - 2 * progress);
|
||||
};
|
||||
const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3);
|
||||
const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1);
|
||||
const point = (cx, cy, radius, degrees) => {
|
||||
const radians = degrees * Math.PI / 180;
|
||||
return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius];
|
||||
};
|
||||
|
||||
class HeavenLoadingCanvas {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.context = canvas.getContext("2d");
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
this.dpr = 1;
|
||||
this.scene = "hexagram";
|
||||
this.data = {};
|
||||
this.startedAt = 0;
|
||||
this.frameId = 0;
|
||||
this.running = false;
|
||||
this.completingAt = 0;
|
||||
this.completionResolve = null;
|
||||
this.completionTimer = 0;
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
this.theme = document.documentElement.dataset.theme || "dark";
|
||||
this.stars = this.createStars(this.reducedMotion ? 48 : 150);
|
||||
}
|
||||
|
||||
createStars(count) {
|
||||
let seed = 24681357;
|
||||
const random = () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 4294967296;
|
||||
};
|
||||
return Array.from({ length: count }, () => ({
|
||||
x: random(),
|
||||
y: random(),
|
||||
radius: 0.3 + random() * 1.3,
|
||||
phase: random() * Math.PI * 2,
|
||||
speed: 0.00015 + random() * 0.0004,
|
||||
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
|
||||
}));
|
||||
}
|
||||
|
||||
start(scene, data = {}) {
|
||||
this.theme = applyLoadingPalette();
|
||||
const nextScene = scene === "fortune" ? "fortune" : "hexagram";
|
||||
if (this.running && this.scene === nextScene) {
|
||||
this.data = data;
|
||||
return;
|
||||
}
|
||||
this.stop();
|
||||
this.scene = nextScene;
|
||||
this.data = data;
|
||||
this.startedAt = performance.now();
|
||||
this.running = true;
|
||||
this.canvas.dataset.scene = this.scene;
|
||||
this.canvas.dataset.running = "true";
|
||||
this.canvas.dataset.looping = "true";
|
||||
this.resizeObserver.observe(this.canvas);
|
||||
this.resize();
|
||||
if (this.reducedMotion) {
|
||||
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
|
||||
} else {
|
||||
this.frameId = requestAnimationFrame((now) => this.frame(now));
|
||||
}
|
||||
}
|
||||
|
||||
complete() {
|
||||
if (!this.running || this.reducedMotion) {
|
||||
this.stop();
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this.completionResolve) return this.completionPromise;
|
||||
this.completingAt = performance.now();
|
||||
this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; });
|
||||
this.completionTimer = global.setTimeout(() => this.stop(), 2200);
|
||||
return this.completionPromise;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.frameId) cancelAnimationFrame(this.frameId);
|
||||
this.frameId = 0;
|
||||
this.running = false;
|
||||
this.completingAt = 0;
|
||||
if (this.completionTimer) global.clearTimeout(this.completionTimer);
|
||||
this.completionTimer = 0;
|
||||
this.resizeObserver.disconnect();
|
||||
this.canvas.dataset.running = "false";
|
||||
this.canvas.dataset.looping = "false";
|
||||
if (this.completionResolve) this.completionResolve();
|
||||
this.completionResolve = null;
|
||||
this.completionPromise = null;
|
||||
}
|
||||
|
||||
resize() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.round(rect.width));
|
||||
const height = Math.max(1, Math.round(rect.height));
|
||||
if (width === this.width && height === this.height) return;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.dpr = Math.min(global.devicePixelRatio || 1, 2);
|
||||
this.canvas.width = Math.round(width * this.dpr);
|
||||
this.canvas.height = Math.round(height * this.dpr);
|
||||
this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||
if (this.running && this.reducedMotion) {
|
||||
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
|
||||
}
|
||||
}
|
||||
|
||||
frame(now) {
|
||||
if (!this.running) return;
|
||||
if (this.completingAt) {
|
||||
const duration = this.scene === "fortune" ? 1800 : 1700;
|
||||
const progress = clamp01((now - this.completingAt) / duration);
|
||||
this.drawCompletion(progress, now);
|
||||
if (progress >= 1) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL;
|
||||
const elapsed = Math.max(0, now - this.startedAt);
|
||||
const timeline = elapsed % total;
|
||||
this.canvas.dataset.cycle = String(Math.floor(elapsed / total));
|
||||
this.draw(timeline, now);
|
||||
}
|
||||
this.frameId = requestAnimationFrame((time) => this.frame(time));
|
||||
}
|
||||
|
||||
draw(time, now) {
|
||||
if (this.width <= 1 || this.height <= 1) return;
|
||||
this.drawBackground(now);
|
||||
if (this.scene === "fortune") this.drawFortune(time, now);
|
||||
else this.drawHexagram(time, now);
|
||||
}
|
||||
|
||||
drawBackground(now) {
|
||||
const currentTheme = document.documentElement.dataset.theme || "dark";
|
||||
if (currentTheme !== this.theme) this.theme = applyLoadingPalette();
|
||||
const { context: ctx, width, height } = this;
|
||||
const cx = width / 2;
|
||||
const cy = height * 0.4;
|
||||
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
|
||||
gradient.addColorStop(0, PAPER_CENTER);
|
||||
gradient.addColorStop(0.52, PAPER_MIDDLE);
|
||||
gradient.addColorStop(1, PAPER);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
for (const star of this.stars) {
|
||||
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.fillStyle = PARTICLE_COLORS[star.colorIndex];
|
||||
const y = ((star.y + now * star.speed) % 1) * height;
|
||||
ctx.fillRect(star.x * width, y, star.radius, star.radius);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) {
|
||||
if (!text || alpha <= 0) return;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
if (maxWidth) ctx.fillText(text, x, y, maxWidth);
|
||||
else ctx.fillText(text, x, y);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
node(x, y, radius, color, alpha = 1, glow = 0) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
line(x1, y1, x2, y2, color, alpha = 1, width = 1) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
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) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = INK;
|
||||
ctx.shadowColor = GOLD;
|
||||
ctx.shadowBlur = glow;
|
||||
if (yang) {
|
||||
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth);
|
||||
} else {
|
||||
const gap = width * 0.18;
|
||||
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
|
||||
ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) {
|
||||
const gap = lineWidth * 1.7;
|
||||
const top = cy - (bits.length - 1) * gap / 2;
|
||||
bits.forEach((bit, index) => {
|
||||
this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow);
|
||||
});
|
||||
}
|
||||
|
||||
stageAlpha(time, start, end, fade = 300, hold = false) {
|
||||
const enter = smooth(start, start + fade, time);
|
||||
return hold ? enter : enter * (1 - smooth(end - fade, end, time));
|
||||
}
|
||||
|
||||
fortuneStages() {
|
||||
const sixQi = this.data.sixQi || {};
|
||||
const pillar = this.data.yearPillar || "岁运";
|
||||
const movement = this.data.movement || "中运合参";
|
||||
const sitian = sixQi.sitian || "司天气候";
|
||||
return [
|
||||
[0, 2100, "五 运", "木火土金水,五运相袭,周而复始"],
|
||||
[2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"],
|
||||
[3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"],
|
||||
[5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"],
|
||||
[7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`],
|
||||
[11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"],
|
||||
];
|
||||
}
|
||||
|
||||
drawFooter(time, now, total, stages, scene) {
|
||||
const { context: ctx, width, height } = this;
|
||||
const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0];
|
||||
const labelAlpha = smooth(stage[0], stage[0] + 300, time)
|
||||
* (1 - smooth(stage[1] - 250, stage[1], time));
|
||||
this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600");
|
||||
this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32);
|
||||
|
||||
const baseSlotWidth = 34;
|
||||
const baseSlotHeight = 5;
|
||||
const baseSlotGap = 12;
|
||||
const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5;
|
||||
const fit = Math.min(1, (width - 28) / baseTotalWidth);
|
||||
const slotWidth = baseSlotWidth * fit;
|
||||
const slotHeight = baseSlotHeight * fit;
|
||||
const slotGap = baseSlotGap * fit;
|
||||
const totalWidth = slotWidth * 6 + slotGap * 5;
|
||||
const filled = Math.min(6, Math.floor(time / (total / 6)));
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap);
|
||||
const y = height - 56;
|
||||
const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.16;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, slotWidth, slotHeight);
|
||||
ctx.restore();
|
||||
if (index < filled) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.fillRect(x, y, slotWidth, slotHeight);
|
||||
ctx.restore();
|
||||
} else if (index === filled) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200);
|
||||
ctx.fillStyle = color;
|
||||
const progress = (time % (total / 6)) / (total / 6);
|
||||
ctx.fillRect(x, y, slotWidth * progress, slotHeight);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
const dots = ".".repeat(1 + Math.floor(now / 450) % 3);
|
||||
const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中";
|
||||
this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75);
|
||||
}
|
||||
|
||||
drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * 0.13;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
const breath = 1 + 0.006 * Math.sin(now / 620);
|
||||
TRIGRAMS.forEach((trigram, index) => {
|
||||
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1;
|
||||
if (progress <= 0) return;
|
||||
const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle);
|
||||
this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8);
|
||||
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) {
|
||||
const { width, height } = this;
|
||||
const cx = width / 2;
|
||||
const cy = height * 0.4;
|
||||
const scale = Math.min(width, Math.max(1, height - 150));
|
||||
if (time < 1800) {
|
||||
const alpha = this.stageAlpha(time, 0, 1800);
|
||||
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) {
|
||||
const progress = ((now / 1500) + ring / 3) % 1;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = (1 - progress) * 0.22 * alpha;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
if (time >= 1800 && time < 3300) {
|
||||
const alpha = this.stageAlpha(time, 1800, 3300);
|
||||
const progress = easeOut((time - 1850) / 850);
|
||||
const yaoWidth = scale * 0.19 * progress;
|
||||
const yaoLine = Math.max(scale * 0.013, 5);
|
||||
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
|
||||
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) {
|
||||
const alpha = this.stageAlpha(time, 3300, 4700);
|
||||
const distance = scale * 0.085;
|
||||
const yaoWidth = Math.max(scale * 0.055, 28);
|
||||
const yaoLine = Math.max(scale * 0.009, 3.5);
|
||||
SIXIANG.forEach((symbol, index) => {
|
||||
const progress = easeOut((time - 3330 - index * 160) / 520);
|
||||
if (progress <= 0) return;
|
||||
const x = cx + symbol.dx * distance;
|
||||
const y = cy + symbol.dy * distance;
|
||||
this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10);
|
||||
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
|
||||
});
|
||||
}
|
||||
const trigramRadius = scale * 0.215;
|
||||
const trigramWidth = Math.max(scale * 0.052, 26);
|
||||
const trigramLine = Math.max(scale * 0.0075, 3);
|
||||
if (time >= 4700 && time < 6800) {
|
||||
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
|
||||
}
|
||||
if (time >= 6800 && time < 10800) {
|
||||
const alpha = this.stageAlpha(time, 6800, 10800, 350);
|
||||
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time);
|
||||
const ringRadius = scale * 0.365;
|
||||
const hexWidth = Math.max(scale * 0.026, 13);
|
||||
const hexLine = Math.max(scale * 0.0042, 1.6);
|
||||
const count = Math.floor(clamp01((time - 7000) / 3600) * 64);
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64);
|
||||
this.node(x, y, 1.4, GOLD, alpha * 0.14);
|
||||
if (index < count) {
|
||||
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
|
||||
if (freshness > 0) {
|
||||
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) {
|
||||
const current = count - 1;
|
||||
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
|
||||
const pop = 1 + 0.22 * (1 - popTime);
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (time >= 10800) {
|
||||
const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420);
|
||||
const progress = easeOut((time - 10850) / 1150);
|
||||
const radius = scale * 0.365 * (1 - progress);
|
||||
for (let index = 0; index < 64 && radius >= 8; index += 1) {
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64);
|
||||
this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha);
|
||||
}
|
||||
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
|
||||
}
|
||||
this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram");
|
||||
}
|
||||
|
||||
drawFortune(time, now) {
|
||||
const { width, height } = this;
|
||||
const cx = width / 2;
|
||||
const cy = height * 0.4;
|
||||
const scale = Math.min(width, Math.max(1, height - 150));
|
||||
if (time < 2100) this.drawFiveMovements(time, now, 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 >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale);
|
||||
if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale);
|
||||
if (time >= 11000) {
|
||||
const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420);
|
||||
const progress = easeOut((time - 11050) / 1200);
|
||||
const radius = scale * 0.30 * (1 - progress);
|
||||
QI6.forEach((qi, index) => {
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 60);
|
||||
if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8);
|
||||
});
|
||||
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
|
||||
}
|
||||
this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune");
|
||||
}
|
||||
|
||||
drawFiveMovements(time, now, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 0, 2100);
|
||||
const radius = scale * 0.17;
|
||||
const nodeRadius = Math.max(scale * 0.018, 9);
|
||||
const elements = [
|
||||
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
|
||||
];
|
||||
const positions = {};
|
||||
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) => {
|
||||
const progress = easeOut((time - 500 - index * 170) / 500);
|
||||
if (progress <= 0) return;
|
||||
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;
|
||||
positions[element] = [x, y];
|
||||
this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16);
|
||||
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
|
||||
const direction = element === "土" ? "中央土" : { 木: "东方木", 火: "南方火", 金: "西方金", 水: "北方水" }[element];
|
||||
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
|
||||
});
|
||||
const order = ["木", "火", "土", "金", "水"];
|
||||
order.forEach((element, index) => {
|
||||
const from = positions[element];
|
||||
const to = positions[order[(index + 1) % order.length]];
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
drawStems(time, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 2100, 3900);
|
||||
const stems = "甲乙丙丁戊己庚辛壬癸";
|
||||
const movements = ["土", "金", "水", "木", "火"];
|
||||
const radius = scale * 0.30;
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
|
||||
if (progress <= 0) continue;
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 36);
|
||||
const element = movements[index % 5];
|
||||
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
|
||||
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
|
||||
}
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
|
||||
const angle = -90 + index * 36;
|
||||
const [x1, y1] = point(cx, cy, radius, angle);
|
||||
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
|
||||
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
drawBranches(time, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 3900, 5800);
|
||||
const branches = "子丑寅卯辰巳午未申酉戌亥";
|
||||
const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"];
|
||||
const radius = scale * 0.31;
|
||||
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
|
||||
const [x, y] = point(cx, cy, radius, branchAngle(index));
|
||||
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
|
||||
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
|
||||
}
|
||||
qiNames.forEach((name, index) => {
|
||||
const progress = smooth(4900 + index * 130, 5200 + index * 130, time);
|
||||
const [x1, y1] = point(cx, cy, radius, branchAngle(index));
|
||||
const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6));
|
||||
const element = QI6.find((item) => item.name === name)?.element || "土";
|
||||
this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4);
|
||||
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
|
||||
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
|
||||
});
|
||||
}
|
||||
|
||||
drawSixQi(time, now, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 5800, 7900);
|
||||
const radius = scale * 0.27;
|
||||
const drift = now * 0.004;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * 0.13;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
QI6.forEach((qi, index) => {
|
||||
const progress = easeOut((time - 5850 - index * 180) / 550);
|
||||
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
|
||||
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
|
||||
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
|
||||
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
|
||||
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, 24);
|
||||
}
|
||||
|
||||
drawAnnualQi(time, now, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 7900, 11000, 350);
|
||||
const sixQi = this.data.sixQi || {};
|
||||
const pillar = this.data.yearPillar || "岁运";
|
||||
const movement = this.data.movement || "中运合参";
|
||||
const sitian = sixQi.sitian || "司天气候";
|
||||
const zaiquan = sixQi.zaiquan || "在泉气化";
|
||||
const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1));
|
||||
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
|
||||
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
|
||||
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
|
||||
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
|
||||
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
|
||||
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
|
||||
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) => {
|
||||
const progress = smooth(9200 + index * 260, 9480 + index * 260, time);
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 60);
|
||||
const current = index + 1 === currentStep;
|
||||
const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0;
|
||||
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) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
|
||||
ctx.strokeStyle = CINNABAR;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "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);
|
||||
});
|
||||
}
|
||||
|
||||
drawCompletion(progress, now) {
|
||||
this.drawBackground(now);
|
||||
if (this.scene === "fortune") {
|
||||
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
|
||||
} else {
|
||||
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
|
||||
})(window);
|
||||
@@ -6,6 +6,7 @@ import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
|
||||
import ScreenerPage from "../../pages/screener/ScreenerPage.vue";
|
||||
import MentorPage from "../../pages/mentor/MentorPage.vue";
|
||||
import HeavenPage from "../../pages/heaven/HeavenPage.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
@@ -33,6 +34,7 @@ const implementedMarket = computed(() =>
|
||||
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
|
||||
<ScreenerPage v-else-if="workspace.key === 'screener'" />
|
||||
<MentorPage v-else-if="workspace.key === 'mentor'" />
|
||||
<HeavenPage v-else-if="workspace.key === 'heaven'" />
|
||||
<main v-else class="page-frame">
|
||||
<header class="page-header">
|
||||
<h1>{{ workspace.title }}</h1>
|
||||
|
||||
Vendored
+8
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
HeavenLoadingCanvas?: new (canvas: HTMLCanvasElement) => {
|
||||
start: (scene: "hexagram" | "fortune", data?: Record<string, unknown>) => void;
|
||||
complete: () => Promise<void>;
|
||||
stop: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import "./shared/styles/market-workspace.css";
|
||||
import "./shared/styles/market-insights.css";
|
||||
import "./shared/styles/screener.css";
|
||||
import "./shared/styles/mentor.css";
|
||||
import "./shared/styles/heaven.css";
|
||||
import "./shared/styles/heaven-fortune.css";
|
||||
import "./shared/styles/heaven-heart.css";
|
||||
import "./shared/styles/system.css";
|
||||
import "./shared/styles/mobile.css";
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
const props = defineProps<{ field: Record<string, any> | null; daily: HeavenReading | null; disabled: boolean }>();
|
||||
const emit = defineEmits<{ interpret: [reading: HeavenReading]; saved: [reading: HeavenReading] }>();
|
||||
const ui = useUiStore();
|
||||
const industriesOpen = ref(false);
|
||||
const loading = ref(false);
|
||||
const layerMarks = ["壹", "贰", "叁"];
|
||||
|
||||
async function interpret(): Promise<void> {
|
||||
if (props.disabled || loading.value) return;
|
||||
if (props.daily?.status === "complete") {
|
||||
emit("interpret", props.daily);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await heavenApi.fortune(props.field?.date);
|
||||
emit("saved", response.reading);
|
||||
emit("interpret", response.reading);
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "解运准备失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="field" class="heaven-panel fortune-panel">
|
||||
<div class="heaven-fortune-grid">
|
||||
<article class="card heaven-calendar-card">
|
||||
<div class="heaven-date-seal"><span>{{ field.date.slice(5) }}</span><small>{{ field.solar_term.current }}后</small></div>
|
||||
<div><p class="muted">{{ field.lunar_date }}</p><h2>{{ field.pillars.year }}年 · {{ field.pillars.month }}月 · {{ field.pillars.day }}日</h2><p>下一节气 {{ field.solar_term.next }}</p></div>
|
||||
</article>
|
||||
<article class="card heaven-phrase-card">
|
||||
<span>当日断语</span><h2>{{ field.phrase }}</h2><p>{{ field.movement.element }}运{{ field.movement.tendency }} · {{ field.six_qi.step_name }} · 客{{ field.six_qi.guest }}加临主{{ field.six_qi.host }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="heaven-qi-layout">
|
||||
<article class="card heaven-qi-layers">
|
||||
<header class="card-header"><h2>三层气机</h2><span class="tag">确定性历法</span></header>
|
||||
<div class="heaven-layer-list">
|
||||
<div v-for="(layer, index) in field.layers" :key="layer.label" class="heaven-layer-row"><span>{{ layerMarks[Number(index)] }}</span><div><strong>{{ layer.label }} · {{ layer.dominant }}</strong><p>{{ layer.summary }}</p></div></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="heaven-personal">
|
||||
<span class="muted">个人合参</span>
|
||||
<template v-if="field.personal"><h3>{{ field.personal.day_master_element }}日主 · 当日合参</h3><p>{{ field.personal.tone }}</p><small>{{ field.personal.notice }}</small></template>
|
||||
<template v-else><h3>尚未设置个人资料</h3><p>可在账户设置的个人资料中补充出生信息。</p></template>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card heaven-industries">
|
||||
<button class="heaven-section-toggle" type="button" @click="industriesOpen = !industriesOpen"><span>五行对应行业</span><span>{{ industriesOpen ? '收起' : '展开' }}</span></button>
|
||||
<div v-if="industriesOpen" class="heaven-industry-grid">
|
||||
<div v-for="group in field.sector_catalog" :key="group.element"><strong>{{ group.element }}</strong><p>{{ group.industries.join(' · ') }}</p></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="heaven-fortune-actions"><p>{{ field.notice }}</p><button class="btn btn-primary" type="button" :disabled="disabled || loading" @click="interpret">{{ daily?.status === 'complete' ? '已解运 · 查看结果' : loading ? '准备中' : '解运' }}</button></div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import HexagramGraphic from "./HexagramGraphic.vue";
|
||||
import { breathState } from "./heartTiming";
|
||||
|
||||
const props = defineProps<{ disabled: boolean }>();
|
||||
const emit = defineEmits<{ interpret: [reading: HeavenReading] }>();
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
const stage = ref<"still" | "breath" | "cast" | "thought" | "result">("still");
|
||||
const breathWord = ref("静");
|
||||
const breathPhase = ref("prepare");
|
||||
const incense = ref(0);
|
||||
const values = ref<number[]>([]);
|
||||
const coinFaces = ref<string[]>(["front", "back", "front"]);
|
||||
const casting = ref(false);
|
||||
const firstThought = ref(false);
|
||||
const result = ref<any>(null);
|
||||
const readingId = ref(0);
|
||||
const muted = ref(false);
|
||||
let breathTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let breathStarted = 0;
|
||||
const positions = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"];
|
||||
const breathClass = computed(() => `is-${breathPhase.value}`);
|
||||
|
||||
function startBreath(): void {
|
||||
if (props.disabled) return;
|
||||
stage.value = "breath";
|
||||
breathStarted = performance.now();
|
||||
updateBreath();
|
||||
breathTimer = setInterval(updateBreath, 100);
|
||||
}
|
||||
|
||||
function updateBreath(): void {
|
||||
const elapsed = performance.now() - breathStarted;
|
||||
const state = breathState(elapsed);
|
||||
incense.value = state.progress;
|
||||
breathWord.value = state.word;
|
||||
breathPhase.value = state.phase;
|
||||
if (state.phase === "complete") {
|
||||
clearBreath();
|
||||
}
|
||||
}
|
||||
|
||||
function clearBreath(): void {
|
||||
if (breathTimer) clearInterval(breathTimer);
|
||||
breathTimer = undefined;
|
||||
}
|
||||
|
||||
async function cast(): Promise<void> {
|
||||
if (casting.value || values.value.length >= 6 || props.disabled) return;
|
||||
casting.value = true;
|
||||
try {
|
||||
const response = await heavenApi.heartLine(market.selectedDate, values.value);
|
||||
coinFaces.value = response.faces;
|
||||
values.value = response.values;
|
||||
if (values.value.length === 6) stage.value = "thought";
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "投掷未完成");
|
||||
} finally {
|
||||
window.setTimeout(() => { casting.value = false; }, 450);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmThought(): Promise<void> {
|
||||
if (!firstThought.value || values.value.length !== 6) return;
|
||||
try {
|
||||
const response = await heavenApi.completeHeart(market.selectedDate, values.value);
|
||||
result.value = response.result;
|
||||
readingId.value = response.reading_id;
|
||||
stage.value = "result";
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "成卦失败");
|
||||
}
|
||||
}
|
||||
|
||||
function reading(): HeavenReading {
|
||||
return {
|
||||
id: readingId.value,
|
||||
mode: "heart",
|
||||
date: result.value.date,
|
||||
subject_key: "",
|
||||
result: result.value,
|
||||
interpretation: "",
|
||||
status: "pending",
|
||||
created_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
clearBreath();
|
||||
stage.value = "still";
|
||||
breathWord.value = "静";
|
||||
breathPhase.value = "prepare";
|
||||
incense.value = 0;
|
||||
values.value = [];
|
||||
result.value = null;
|
||||
readingId.value = 0;
|
||||
firstThought.value = false;
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearBreath);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="heaven-panel heart-panel">
|
||||
<div class="heaven-heart-toolbar"><button class="btn btn-ghost btn-small" type="button" @click="muted = !muted">{{ muted ? '开启声音' : '静音' }}</button><button v-if="stage !== 'still'" class="btn btn-ghost btn-small" type="button" @click="reset">重新观心</button></div>
|
||||
<article v-if="stage === 'still'" class="card heart-still">
|
||||
<span class="heaven-heart-mark">心</span><h2>把所问之事留在心里</h2><p>不必输入,不必说明。先让念头安静下来,再看第一念如何浮现。</p><button class="btn btn-primary" type="button" :disabled="disabled" @click="startBreath">开始静心</button>
|
||||
</article>
|
||||
<article v-else-if="stage === 'breath'" class="card heart-breath">
|
||||
<div class="heart-ripple" :class="breathClass"><i /><i /><i /><strong>{{ breathWord }}</strong></div>
|
||||
<div class="heart-incense"><span>一炷香</span><div><i :style="{ width: `${incense * 100}%` }" /></div></div>
|
||||
<button v-if="breathPhase === 'complete'" class="btn btn-primary" type="button" @click="stage = 'cast'">静心完成,开始起卦</button>
|
||||
</article>
|
||||
<article v-else-if="stage === 'cast' || stage === 'thought'" class="heart-casting-layout">
|
||||
<section class="card heart-coins-card">
|
||||
<p class="muted">依次投掷六次,每次只得一爻</p>
|
||||
<div class="heart-coins" :class="{ 'is-casting': casting }"><span v-for="(face, index) in coinFaces" :key="index" class="heart-coin" :class="face"><i>{{ face === 'front' ? '乾' : '元' }}</i><small>{{ face === 'front' ? '通宝' : '坤仪' }}</small></span></div>
|
||||
<button v-if="stage === 'cast'" class="btn btn-primary" type="button" :disabled="casting" @click="cast">{{ casting ? '铜钱落定' : `投掷${positions[values.length]}` }}</button>
|
||||
<div v-else class="heart-thought"><label><input v-model="firstThought" type="checkbox" /> 我已记住此刻浮现的第一念</label><button class="btn btn-primary" type="button" :disabled="!firstThought" @click="confirmThought">确认第一念,完成起卦</button></div>
|
||||
</section>
|
||||
<section class="card heart-lines-card">
|
||||
<div v-for="(position, index) in positions" :key="position" class="heart-cast-line" :class="{ 'is-revealed': values[index] }"><span>{{ position }}</span><template v-if="values[index]"><span class="heaven-line-mini" :class="{ yin: values[index] % 2 === 0 }"><i /><i /></span><strong>{{ values[index] % 2 ? '阳爻' : '阴爻' }} · {{ values[index] }}</strong></template><em v-else>未得</em></div>
|
||||
</section>
|
||||
</article>
|
||||
<article v-else-if="result" class="card heart-result">
|
||||
<HexagramGraphic :hexagram="result.hexagram" />
|
||||
<div><span class="muted">卦辞</span><h2>{{ result.hexagram.name }}</h2><p>{{ result.hexagram.text }}</p><small>{{ result.notice }}</small></div>
|
||||
<button class="btn btn-primary" type="button" @click="emit('interpret', reading())">解卦</button>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading, type HeavenSetup, type ReadingMode } from "../../shared/api/heaven";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import FortunePanel from "./FortunePanel.vue";
|
||||
import HeartPanel from "./HeartPanel.vue";
|
||||
import InterpretDialog from "./InterpretDialog.vue";
|
||||
import TrendPanel from "./TrendPanel.vue";
|
||||
|
||||
const market = useMarketStore();
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
const mode = ref<ReadingMode>("trend");
|
||||
const setup = ref<HeavenSetup | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const activeReading = ref<HeavenReading | null>(null);
|
||||
const locked = computed(() => !session.account?.smart_access);
|
||||
const labels: Record<ReadingMode, { title: string; subtitle: string }> = {
|
||||
trend: { title: "观势", subtitle: "以客观行情量化三才六爻" },
|
||||
fortune: { title: "观气", subtitle: "以五运六气观照当日气机" },
|
||||
heart: { title: "观心", subtitle: "静心、起卦,察见第一念" },
|
||||
};
|
||||
|
||||
async function load(): Promise<void> {
|
||||
setup.value = null;
|
||||
error.value = "";
|
||||
if (locked.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
setup.value = await heavenApi.setup(market.selectedDate);
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : "问天数据读取失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openInterpret(reading: HeavenReading): void {
|
||||
activeReading.value = reading;
|
||||
if (!setup.value?.history.some((item) => item.id === reading.id)) {
|
||||
setup.value?.history.unshift(reading);
|
||||
}
|
||||
}
|
||||
|
||||
function saveFortune(reading: HeavenReading): void {
|
||||
if (!setup.value) return;
|
||||
setup.value.daily_fortune = reading;
|
||||
if (!setup.value.history.some((item) => item.id === reading.id)) setup.value.history.unshift(reading);
|
||||
}
|
||||
|
||||
function completed(reading: HeavenReading): void {
|
||||
if (!setup.value) return;
|
||||
const index = setup.value.history.findIndex((item) => item.id === reading.id);
|
||||
if (index >= 0) setup.value.history[index] = reading;
|
||||
if (reading.mode === "fortune") setup.value.daily_fortune = reading;
|
||||
}
|
||||
|
||||
watch([() => market.selectedDate, locked], load, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame heaven-page">
|
||||
<header class="page-header heaven-page-header">
|
||||
<div><h1>问天</h1><p class="page-subtitle">观天之道 · 执天之行 · 数据日期 {{ setup?.date || market.selectedDate }}</p></div>
|
||||
<button class="btn btn-small" type="button" :disabled="locked || !setup?.history.length" @click="activeReading = setup?.history[0] || null">历史记录</button>
|
||||
</header>
|
||||
<div v-if="locked" class="notice notice-warning membership-lock"><span><strong>问天仅对会员开放</strong>,开通会员后可使用观势、观气与观心。</span><button class="btn btn-small" type="button" @click="ui.openDialog('membership')">查看会员状态</button></div>
|
||||
<nav class="heaven-mode-tabs" aria-label="问天模式">
|
||||
<button v-for="(item, key) in labels" :key="key" type="button" :class="{ active: mode === key }" @click="mode = key"><strong>{{ item.title }}</strong><span>{{ item.subtitle }}</span></button>
|
||||
</nav>
|
||||
<div v-if="loading" class="card workspace-state">正在推演当日基础气机</div>
|
||||
<div v-else-if="error" class="notice notice-warning">{{ error }}</div>
|
||||
<div v-else :class="{ 'locked-content': locked }" :aria-disabled="locked">
|
||||
<TrendPanel v-if="mode === 'trend'" :disabled="locked" @interpret="openInterpret" />
|
||||
<FortunePanel v-else-if="mode === 'fortune'" :field="setup?.fortune || null" :daily="setup?.daily_fortune || null" :disabled="locked" @interpret="openInterpret" @saved="saveFortune" />
|
||||
<HeartPanel v-else :disabled="locked" @interpret="openInterpret" />
|
||||
</div>
|
||||
<InterpretDialog v-if="activeReading" :reading="activeReading" :history="setup?.history || []" @close="activeReading = null" @completed="completed" />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ hexagram: Record<string, any>; compact?: boolean }>();
|
||||
|
||||
function transformedValue(value: number): number {
|
||||
return value === 6 ? 7 : value === 9 ? 8 : value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="heaven-hex-pair" :class="{ 'heaven-hex-compact': compact }">
|
||||
<div class="heaven-hex-symbol">
|
||||
<strong>{{ hexagram.name }}</strong>
|
||||
<div class="heaven-hex-lines">
|
||||
<span v-for="line in [...hexagram.lines].reverse()" :key="line.position" class="heaven-yao" :class="{ yin: line.value % 2 === 0, moving: line.moving }"><i /><i /></span>
|
||||
</div>
|
||||
<small>{{ hexagram.outer_trigram }}上 · {{ hexagram.inner_trigram }}下</small>
|
||||
</div>
|
||||
<span class="heaven-change-arrow" aria-label="变化为">→</span>
|
||||
<div class="heaven-hex-symbol">
|
||||
<strong>{{ hexagram.transformed.name }}</strong>
|
||||
<div class="heaven-hex-lines">
|
||||
<span v-for="line in [...hexagram.lines].reverse()" :key="line.position" class="heaven-yao" :class="{ yin: transformedValue(line.value) % 2 === 0 }"><i /><i /></span>
|
||||
</div>
|
||||
<small>{{ hexagram.transformed.outer_trigram }}上 · {{ hexagram.transformed.inner_trigram }}下</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading, type HeavenStreamEvent } from "../../shared/api/heaven";
|
||||
import BaseDialog from "../../shared/components/BaseDialog.vue";
|
||||
|
||||
const props = defineProps<{ reading: HeavenReading; history: HeavenReading[] }>();
|
||||
const emit = defineEmits<{ close: []; completed: [reading: HeavenReading] }>();
|
||||
const tab = ref<"current" | "history">("current");
|
||||
const active = ref(props.reading);
|
||||
const answer = ref(props.reading.interpretation || "");
|
||||
const loading = ref(!props.reading.interpretation);
|
||||
const error = ref("");
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
let animation: any = null;
|
||||
let controller: AbortController | null = null;
|
||||
const title = computed(() => ({ trend: "解势", fortune: "解运", heart: "解卦" })[active.value.mode]);
|
||||
const modeHistory = computed(() => props.history.filter((item) => item.mode === active.value.mode));
|
||||
|
||||
async function run(): Promise<void> {
|
||||
if (active.value.interpretation) {
|
||||
answer.value = active.value.interpretation;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
answer.value = "";
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
await nextTick();
|
||||
startAnimation();
|
||||
controller = new AbortController();
|
||||
try {
|
||||
await heavenApi.interpret(
|
||||
active.value.id,
|
||||
(event: HeavenStreamEvent) => {
|
||||
if (event.type === "delta") answer.value += event.content ?? "";
|
||||
if (event.type === "error") error.value = event.message ?? "智能解读未完成";
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
await animation?.complete?.();
|
||||
active.value = { ...active.value, interpretation: answer.value, status: error.value ? "error" : "complete" };
|
||||
emit("completed", active.value);
|
||||
} catch (reason) {
|
||||
if (!(reason instanceof DOMException && reason.name === "AbortError")) {
|
||||
error.value = reason instanceof Error ? reason.message : "智能解读服务暂不可用";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
animation?.stop?.();
|
||||
controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startAnimation(): void {
|
||||
if (!canvas.value || !window.HeavenLoadingCanvas) return;
|
||||
animation = new window.HeavenLoadingCanvas(canvas.value);
|
||||
const result = active.value.result;
|
||||
animation.start(active.value.mode === "fortune" ? "fortune" : "hexagram", {
|
||||
yearPillar: result.pillars?.year ?? "",
|
||||
movement: result.movement?.element ? `${result.movement.element}运${result.movement.tendency}` : "",
|
||||
sixQi: {
|
||||
sitian: result.six_qi?.sitian ?? "",
|
||||
zaiquan: result.six_qi?.zaiquan ?? "",
|
||||
step: result.six_qi?.step ?? 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function selectHistory(reading: HeavenReading): void {
|
||||
controller?.abort();
|
||||
animation?.stop?.();
|
||||
active.value = reading;
|
||||
tab.value = "current";
|
||||
void run();
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
controller?.abort();
|
||||
animation?.stop?.();
|
||||
emit("close");
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
onBeforeUnmount(() => {
|
||||
controller?.abort();
|
||||
animation?.stop?.();
|
||||
});
|
||||
watch(() => props.reading, (value) => { active.value = value; void run(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDialog :title="title" wide :close-on-backdrop="!loading" @close="close">
|
||||
<div class="heaven-dialog-tabs"><button type="button" :class="{ active: tab === 'current' }" @click="tab = 'current'">本次解读</button><button type="button" :class="{ active: tab === 'history' }" @click="tab = 'history'">历史记录</button></div>
|
||||
<div v-if="tab === 'history'" class="heaven-history-list">
|
||||
<button v-for="item in modeHistory" :key="item.id" type="button" @click="selectHistory(item)"><strong>{{ item.date }} · {{ item.result.stock?.name || item.result.hexagram?.name || item.result.phrase || title }}</strong><span>{{ item.status === 'complete' ? '已完成' : '未完成' }}</span></button>
|
||||
<p v-if="!modeHistory.length" class="muted">暂无历史记录</p>
|
||||
</div>
|
||||
<div v-else class="heaven-dialog-current">
|
||||
<div v-if="loading" class="heaven-loading-stage"><canvas ref="canvas" /><p>{{ active.mode === 'fortune' ? '气机渐次归位' : '阴阳渐次成象' }}</p></div>
|
||||
<div v-else class="heaven-interpretation"><p v-if="error" class="notice notice-warning">{{ error }}</p><div class="heaven-answer">{{ answer || '本次解读未生成完整内容。' }}</div></div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import HexagramGraphic from "./HexagramGraphic.vue";
|
||||
|
||||
const props = defineProps<{ disabled: boolean }>();
|
||||
const emit = defineEmits<{ interpret: [reading: HeavenReading] }>();
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
const query = ref("");
|
||||
const loading = ref(false);
|
||||
const response = ref<any>(null);
|
||||
const showChecks = ref(false);
|
||||
const showManual = ref(false);
|
||||
const manual = ref<Record<string, string | number>>({});
|
||||
const result = computed(() => response.value?.result);
|
||||
const stock = computed(() => result.value?.stock ?? response.value?.stock);
|
||||
const sector = computed(() => result.value?.sector ?? response.value?.sector);
|
||||
|
||||
async function load(withManual = false): Promise<void> {
|
||||
if (!query.value.trim() || props.disabled) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
response.value = await heavenApi.trend(
|
||||
query.value,
|
||||
market.selectedDate,
|
||||
withManual ? { sector: manual.value } : {},
|
||||
);
|
||||
if (!response.value.ready) showChecks.value = true;
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "观势数据读取失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reading(): HeavenReading {
|
||||
return {
|
||||
id: response.value.reading_id,
|
||||
mode: "trend",
|
||||
date: result.value.trade_date,
|
||||
subject_key: result.value.stock.identifier,
|
||||
result: result.value,
|
||||
interpretation: "",
|
||||
status: "pending",
|
||||
created_at: "",
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="heaven-panel trend-panel">
|
||||
<div class="card heaven-trend-input">
|
||||
<div class="field heaven-stock-field">
|
||||
<label for="heaven-stock">股票代码或名称</label>
|
||||
<div class="heaven-inline-control">
|
||||
<input id="heaven-stock" v-model="query" class="input" placeholder="输入六位代码或股票名称" :disabled="disabled || loading" @keydown.enter="load(false)" />
|
||||
<button class="btn btn-primary" type="button" :disabled="disabled || loading || !query.trim()" @click="load(false)">{{ loading ? '载入中' : '载入' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="stock" class="heaven-current-target">当前标的:<strong>{{ stock.name }}</strong> 申万二级·<strong>{{ sector?.name || '待核验' }}</strong></p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card heaven-awaiting"><span class="heaven-bagua" aria-hidden="true">☰</span><strong>三才六爻正在取象</strong><p>核验个股、申万二级行业、市场与三大指数</p></div>
|
||||
<div v-else-if="!response" class="card heaven-awaiting"><span class="heaven-bagua" aria-hidden="true">☷</span><strong>请输入股票代码或股票名称</strong></div>
|
||||
<template v-else-if="response.ready">
|
||||
<div class="heaven-trend-grid">
|
||||
<article class="card heaven-lines-card">
|
||||
<header class="card-header"><h2>三才六爻</h2><span class="tag">{{ result.trade_date }}</span></header>
|
||||
<div class="heaven-line-list">
|
||||
<div v-for="line in [...result.hexagram.lines].reverse()" :key="line.position" class="heaven-line-row">
|
||||
<span>{{ line.position_name }}</span><span>{{ line.talent }}·{{ line.layer }}</span>
|
||||
<span class="heaven-line-mini" :class="{ yin: line.value % 2 === 0 }"><i /><i /></span>
|
||||
<strong>{{ line.role }}</strong><small class="numeric">{{ (line.score * 100).toFixed(0) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card heaven-outcome-card">
|
||||
<div class="heaven-momentum"><span>势值</span><strong class="numeric">{{ result.momentum_score }}</strong><small>{{ result.momentum_label }}</small></div>
|
||||
<HexagramGraphic :hexagram="result.hexagram" />
|
||||
<p class="heaven-hex-text">{{ result.hexagram.text }}</p>
|
||||
<button class="btn btn-primary" type="button" @click="emit('interpret', reading())">解势</button>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="card heaven-not-ready"><strong>暂不成卦</strong><p>{{ response.message }}</p></div>
|
||||
|
||||
<section v-if="response" class="card heaven-validation">
|
||||
<button class="heaven-section-toggle" type="button" @click="showChecks = !showChecks"><span>六爻数据校验</span><span>{{ showChecks ? '收起' : '展开' }}</span></button>
|
||||
<div v-if="showChecks" class="heaven-check-list">
|
||||
<div v-for="check in response.checks || result?.checks" :key="check.position" class="heaven-check" :class="check.passed ? 'is-pass' : 'is-fail'">
|
||||
<strong>{{ check.position_name }} · {{ check.role }}</strong><span>{{ check.message }}</span><small>{{ check.source === 'manual' ? '用户补录' : check.passed ? '自动通过' : '需要补充' }}</small>
|
||||
</div>
|
||||
<button v-if="!response.ready" class="btn btn-small" type="button" @click="showManual = !showManual">{{ showManual ? '收起手动补录' : '手动补录客观数据' }}</button>
|
||||
<form v-if="showManual" class="heaven-manual-grid" @submit.prevent="load(true)">
|
||||
<label class="field"><span class="field-label">行业涨跌 (%)</span><input v-model="manual.change" class="input" type="number" step="0.01" /></label>
|
||||
<label class="field"><span class="field-label">上涨 / 下跌成分</span><span class="heaven-dual-input"><input v-model="manual.up_count" class="input" type="number" /><input v-model="manual.down_count" class="input" type="number" /></span></label>
|
||||
<label class="field"><span class="field-label">成员总数 / 有效数</span><span class="heaven-dual-input"><input v-model="manual.member_count" class="input" type="number" /><input v-model="manual.quoted_count" class="input" type="number" /></span></label>
|
||||
<label class="field"><span class="field-label">覆盖率 (0-1)</span><input v-model="manual.coverage" class="input" type="number" step="0.01" /></label>
|
||||
<label class="field"><span class="field-label">成分等权涨跌 (%)</span><input v-model="manual.member_equal_change" class="input" type="number" step="0.01" /></label>
|
||||
<label class="field"><span class="field-label">领涨股 / 涨跌</span><span class="heaven-dual-input"><input v-model="manual.leader" class="input" /><input v-model="manual.leading_pct" class="input" type="number" step="0.01" /></span></label>
|
||||
<div class="form-actions"><button class="btn btn-primary" type="submit">重新核验并成卦</button><button class="btn" type="button" @click="manual = {}; load(false)">恢复自动数据</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { breathState } from "./heartTiming";
|
||||
|
||||
describe("heart breathing state", () => {
|
||||
it("uses one second preparation then five 3/2/4 second breaths", () => {
|
||||
expect(breathState(0).word).toBe("静");
|
||||
expect(breathState(999).phase).toBe("prepare");
|
||||
expect(breathState(1_000).word).toBe("吸");
|
||||
expect(breathState(3_999).phase).toBe("inhale");
|
||||
expect(breathState(4_000).word).toBe("顿");
|
||||
expect(breathState(5_999).phase).toBe("pause");
|
||||
expect(breathState(6_000).word).toBe("呼");
|
||||
expect(breathState(9_999).phase).toBe("exhale");
|
||||
expect(breathState(10_000).word).toBe("吸");
|
||||
expect(breathState(45_999).word).toBe("呼");
|
||||
expect(breathState(46_000)).toEqual({ word: "定", phase: "complete", progress: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export type BreathState = {
|
||||
word: "静" | "吸" | "顿" | "呼" | "定";
|
||||
phase: "prepare" | "inhale" | "pause" | "exhale" | "complete";
|
||||
progress: number;
|
||||
};
|
||||
|
||||
export function breathState(elapsedMs: number): BreathState {
|
||||
const elapsed = Math.max(0, elapsedMs);
|
||||
const progress = Math.min(elapsed / 46_000, 1);
|
||||
if (elapsed < 1_000) return { word: "静", phase: "prepare", progress };
|
||||
if (elapsed >= 46_000) return { word: "定", phase: "complete", progress: 1 };
|
||||
const within = (elapsed - 1_000) % 9_000;
|
||||
if (within < 3_000) return { word: "吸", phase: "inhale", progress };
|
||||
if (within < 5_000) return { word: "顿", phase: "pause", progress };
|
||||
return { word: "呼", phase: "exhale", progress };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export type ReadingMode = "trend" | "fortune" | "heart";
|
||||
export type HeavenReading = {
|
||||
id: number;
|
||||
mode: ReadingMode;
|
||||
date: string;
|
||||
subject_key: string;
|
||||
result: Record<string, any>;
|
||||
interpretation: string;
|
||||
status: "pending" | "complete" | "stopped" | "error";
|
||||
created_at: string;
|
||||
};
|
||||
export type HeavenSetup = {
|
||||
date: string;
|
||||
fortune: Record<string, any>;
|
||||
daily_fortune: HeavenReading | null;
|
||||
history: HeavenReading[];
|
||||
};
|
||||
export type HeavenStreamEvent = {
|
||||
type: "delta" | "done" | "error";
|
||||
content?: string;
|
||||
message?: string;
|
||||
cached?: boolean;
|
||||
};
|
||||
|
||||
export const heavenApi = {
|
||||
setup(date: string): Promise<HeavenSetup> {
|
||||
return api.get(`/heaven/setup?date=${encodeURIComponent(date)}`);
|
||||
},
|
||||
trend(query: string, tradeDate: string, manual: Record<string, any> = {}): Promise<any> {
|
||||
return api.post("/heaven/trend/load", { query, trade_date: tradeDate, manual });
|
||||
},
|
||||
fortune(tradeDate: string): Promise<{ reused: boolean; reading: HeavenReading }> {
|
||||
return api.post("/heaven/fortune", { trade_date: tradeDate });
|
||||
},
|
||||
heartLine(tradeDate: string, values: number[]): Promise<any> {
|
||||
return api.post("/heaven/heart/line", { trade_date: tradeDate, values });
|
||||
},
|
||||
completeHeart(tradeDate: string, values: number[]): Promise<any> {
|
||||
return api.post("/heaven/heart/complete", {
|
||||
trade_date: tradeDate,
|
||||
values,
|
||||
first_thought_confirmed: true,
|
||||
});
|
||||
},
|
||||
readings(mode?: ReadingMode, date?: string): Promise<HeavenReading[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (mode) query.set("mode", mode);
|
||||
if (date) query.set("date", date);
|
||||
return api.get(`/heaven/readings?${query}`);
|
||||
},
|
||||
remove(id: number): Promise<{ deleted: number }> {
|
||||
return api.delete(`/heaven/readings/${id}`);
|
||||
},
|
||||
interpret(
|
||||
readingId: number,
|
||||
onEvent: (event: HeavenStreamEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
return api.stream("/heaven/interpret", { reading_id: readingId }, onEvent, signal);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
.heaven-fortune-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(var(--s-320), 0.8fr) minmax(0, 1.2fr);
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.heaven-calendar-card,
|
||||
.heaven-phrase-card {
|
||||
min-height: var(--s-120);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-16);
|
||||
padding: var(--s-16);
|
||||
}
|
||||
|
||||
.heaven-date-seal {
|
||||
width: var(--s-80);
|
||||
height: var(--s-80);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
border: var(--s-1) solid var(--color-heaven);
|
||||
color: var(--color-heaven);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heaven-date-seal span {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--font-18);
|
||||
}
|
||||
|
||||
.heaven-calendar-card h2,
|
||||
.heaven-phrase-card h2 {
|
||||
margin: var(--s-6) 0;
|
||||
color: var(--color-heaven);
|
||||
font-size: var(--font-18);
|
||||
}
|
||||
|
||||
.heaven-phrase-card {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.heaven-qi-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(var(--s-320), 0.6fr);
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.heaven-layer-list {
|
||||
display: grid;
|
||||
padding: var(--s-8) var(--s-14) var(--s-14);
|
||||
}
|
||||
|
||||
.heaven-layer-row {
|
||||
min-height: var(--s-64);
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-44) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--s-12);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.heaven-layer-row > span {
|
||||
color: var(--color-heaven);
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
.heaven-layer-row p,
|
||||
.heaven-personal p {
|
||||
margin-top: var(--s-4);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--s-20);
|
||||
}
|
||||
|
||||
.heaven-personal {
|
||||
align-self: center;
|
||||
padding: var(--s-16);
|
||||
}
|
||||
|
||||
.heaven-personal h3 {
|
||||
margin-top: var(--s-8);
|
||||
color: var(--color-heaven);
|
||||
font-size: var(--font-17);
|
||||
}
|
||||
|
||||
.heaven-personal small {
|
||||
display: block;
|
||||
margin-top: var(--s-12);
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.heaven-industry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--s-8);
|
||||
padding: 0 var(--s-14) var(--s-14);
|
||||
}
|
||||
|
||||
.heaven-industry-grid > div {
|
||||
padding: var(--s-10);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--control-radius);
|
||||
}
|
||||
|
||||
.heaven-industry-grid strong {
|
||||
color: var(--color-heaven);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--font-17);
|
||||
}
|
||||
|
||||
.heaven-industry-grid p {
|
||||
margin-top: var(--s-6);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-12);
|
||||
line-height: var(--s-20);
|
||||
}
|
||||
|
||||
.heaven-fortune-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-16);
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--font-11-5);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
.heaven-heart-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.heart-still,
|
||||
.heart-breath {
|
||||
min-height: var(--s-400);
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--s-14);
|
||||
padding: var(--s-24);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heaven-heart-mark {
|
||||
width: var(--s-80);
|
||||
height: var(--s-80);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: var(--s-1) solid var(--color-heaven);
|
||||
border-radius: var(--radius-round);
|
||||
color: var(--color-heaven);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--font-32);
|
||||
}
|
||||
|
||||
.heart-still p {
|
||||
max-width: var(--s-480);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--s-22);
|
||||
}
|
||||
|
||||
.heart-ripple {
|
||||
position: relative;
|
||||
width: var(--s-180);
|
||||
height: var(--s-180);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.heart-ripple i {
|
||||
position: absolute;
|
||||
inset: var(--s-24);
|
||||
border: var(--s-1) solid var(--color-heaven);
|
||||
border-radius: var(--radius-round);
|
||||
opacity: var(--opacity-muted);
|
||||
transition: transform 3s linear, opacity 2s linear;
|
||||
}
|
||||
|
||||
.heart-ripple i:nth-child(2) { inset: var(--s-16); opacity: 0.42; }
|
||||
.heart-ripple i:nth-child(3) { inset: var(--s-8); opacity: 0.2; }
|
||||
.heart-ripple strong { color: var(--color-heaven); font-family: var(--font-serif); font-size: var(--font-32); }
|
||||
.heart-ripple.is-inhale i { transform: scale(1.18); }
|
||||
.heart-ripple.is-pause i { transform: scale(1.18); opacity: var(--opacity-muted); }
|
||||
.heart-ripple.is-exhale i { transform: scale(0.72); opacity: 0.18; }
|
||||
|
||||
.heart-incense {
|
||||
width: min(100%, var(--s-360));
|
||||
display: grid;
|
||||
gap: var(--s-8);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.heart-incense > div {
|
||||
height: var(--s-2);
|
||||
overflow: hidden;
|
||||
background: var(--color-divider);
|
||||
}
|
||||
|
||||
.heart-incense i {
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: var(--color-heaven);
|
||||
transition: width var(--duration-fast) linear;
|
||||
}
|
||||
|
||||
.heart-casting-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(var(--s-320), 0.8fr);
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.heart-coins-card,
|
||||
.heart-lines-card {
|
||||
min-height: var(--s-360);
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: var(--s-20);
|
||||
padding: var(--s-24);
|
||||
}
|
||||
|
||||
.heart-coins-card {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.heart-coins {
|
||||
display: flex;
|
||||
gap: var(--s-20);
|
||||
perspective: var(--s-400);
|
||||
}
|
||||
|
||||
.heart-coin {
|
||||
width: var(--s-80);
|
||||
height: var(--s-80);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
border: var(--s-4) double var(--color-heaven);
|
||||
border-radius: var(--radius-round);
|
||||
color: var(--color-heaven);
|
||||
background: var(--color-heaven-soft);
|
||||
box-shadow: inset 0 0 0 var(--s-6) var(--color-surface), var(--shadow-card);
|
||||
text-align: center;
|
||||
transition: transform var(--duration-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
.heart-coin.back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.heart-coin i,
|
||||
.heart-coin small {
|
||||
font-style: normal;
|
||||
transform: inherit;
|
||||
}
|
||||
|
||||
.heart-coin i {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--font-17);
|
||||
}
|
||||
|
||||
.heart-coins.is-casting .heart-coin {
|
||||
animation: heaven-coin 0.45s ease-in-out;
|
||||
}
|
||||
|
||||
.heart-thought {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--s-12);
|
||||
}
|
||||
|
||||
.heart-cast-line {
|
||||
min-height: var(--s-44);
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-44) var(--s-96) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--s-12);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.heart-cast-line em {
|
||||
grid-column: 2 / -1;
|
||||
color: var(--color-text-faint);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.heart-cast-line.is-revealed strong {
|
||||
color: var(--color-heaven);
|
||||
}
|
||||
|
||||
.heart-result {
|
||||
min-height: var(--s-360);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(var(--s-240), 0.8fr) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--s-24);
|
||||
padding: var(--s-24);
|
||||
}
|
||||
|
||||
.heart-result h2 {
|
||||
margin: var(--s-8) 0;
|
||||
color: var(--color-heaven);
|
||||
}
|
||||
|
||||
.heart-result p {
|
||||
margin-bottom: var(--s-12);
|
||||
line-height: var(--s-22);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
.heaven-page {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .heaven-page::before {
|
||||
position: fixed;
|
||||
inset: var(--shell-topbar-height) 0 var(--shell-status-height) var(--shell-sidebar-width);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
opacity: 0.12;
|
||||
background-image: radial-gradient(circle, var(--color-heaven-star) var(--s-1), transparent var(--s-1));
|
||||
background-size: var(--s-32) var(--s-32);
|
||||
}
|
||||
|
||||
.heaven-page > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.heaven-page-header h1,
|
||||
.heaven-mode-tabs strong,
|
||||
.heaven-panel h2,
|
||||
.heaven-panel h3 {
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
.heaven-mode-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--s-8);
|
||||
margin-bottom: var(--layout-gap);
|
||||
padding: var(--s-4);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.heaven-mode-tabs button {
|
||||
display: grid;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-8) var(--s-12);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--c-transparent);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.heaven-mode-tabs button:hover,
|
||||
.heaven-mode-tabs button.active {
|
||||
color: var(--color-heaven);
|
||||
background: var(--color-heaven-soft);
|
||||
}
|
||||
|
||||
.heaven-mode-tabs strong {
|
||||
font-size: var(--font-17);
|
||||
}
|
||||
|
||||
.heaven-mode-tabs span {
|
||||
font-size: var(--font-11-5);
|
||||
}
|
||||
|
||||
.heaven-panel {
|
||||
display: grid;
|
||||
gap: var(--layout-gap);
|
||||
padding-bottom: var(--s-20);
|
||||
}
|
||||
|
||||
.heaven-trend-input {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: var(--s-24);
|
||||
padding: var(--s-12) var(--s-16);
|
||||
}
|
||||
|
||||
.heaven-stock-field {
|
||||
width: min(100%, var(--s-480));
|
||||
}
|
||||
|
||||
.heaven-inline-control,
|
||||
.heaven-dual-input {
|
||||
display: flex;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.heaven-inline-control .input,
|
||||
.heaven-dual-input .input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heaven-current-target {
|
||||
align-self: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-13);
|
||||
}
|
||||
|
||||
.heaven-current-target strong {
|
||||
color: var(--color-heaven);
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.heaven-awaiting {
|
||||
min-height: var(--s-240);
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--s-8);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heaven-bagua {
|
||||
width: var(--s-80);
|
||||
height: var(--s-80);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: var(--s-1) solid var(--color-heaven);
|
||||
border-radius: var(--radius-round);
|
||||
color: var(--color-heaven);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--font-32);
|
||||
animation: heaven-turn 12s linear infinite;
|
||||
}
|
||||
|
||||
.heaven-trend-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(var(--s-320), 0.75fr);
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.heaven-lines-card,
|
||||
.heaven-outcome-card {
|
||||
min-height: var(--s-320);
|
||||
}
|
||||
|
||||
.heaven-lines-card .card-header,
|
||||
.heaven-qi-layers .card-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.heaven-line-list {
|
||||
display: grid;
|
||||
padding: var(--s-8) var(--s-14) var(--s-14);
|
||||
}
|
||||
|
||||
.heaven-line-row {
|
||||
min-height: var(--s-40);
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-44) var(--s-56) var(--s-80) minmax(0, 1fr) var(--s-44);
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.heaven-line-row strong {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.heaven-line-row small {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.heaven-line-mini,
|
||||
.heaven-yao {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.heaven-line-mini i,
|
||||
.heaven-yao i {
|
||||
width: 50%;
|
||||
height: var(--s-6);
|
||||
background: var(--color-heaven);
|
||||
}
|
||||
|
||||
.heaven-line-mini:not(.yin) i + i,
|
||||
.heaven-yao:not(.yin) i + i {
|
||||
margin-left: calc(var(--s-1) * -1);
|
||||
}
|
||||
|
||||
.heaven-line-mini.yin,
|
||||
.heaven-yao.yin {
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.heaven-outcome-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
gap: var(--s-8);
|
||||
padding: var(--s-16);
|
||||
}
|
||||
|
||||
.heaven-momentum {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-8);
|
||||
justify-self: stretch;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.heaven-momentum strong {
|
||||
color: var(--color-heaven);
|
||||
font-size: var(--font-32);
|
||||
}
|
||||
|
||||
.heaven-momentum small {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.heaven-hex-pair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-24);
|
||||
}
|
||||
|
||||
.heaven-hex-symbol {
|
||||
width: var(--s-96);
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.heaven-hex-symbol > strong {
|
||||
color: var(--color-heaven);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--font-18);
|
||||
}
|
||||
|
||||
.heaven-hex-lines {
|
||||
width: var(--s-64);
|
||||
display: grid;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.heaven-change-arrow {
|
||||
color: var(--color-heaven);
|
||||
font-size: var(--font-24);
|
||||
}
|
||||
|
||||
.heaven-hex-text {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--s-20);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heaven-not-ready {
|
||||
padding: var(--s-24);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heaven-not-ready strong {
|
||||
color: var(--color-heaven);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--font-18);
|
||||
}
|
||||
|
||||
.heaven-section-toggle {
|
||||
width: 100%;
|
||||
min-height: var(--s-40);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-8) var(--s-14);
|
||||
color: var(--color-text);
|
||||
background: var(--c-transparent);
|
||||
}
|
||||
|
||||
.heaven-check-list {
|
||||
display: grid;
|
||||
gap: var(--s-8);
|
||||
padding: 0 var(--s-14) var(--s-14);
|
||||
}
|
||||
|
||||
.heaven-check {
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-160) minmax(0, 1fr) var(--s-80);
|
||||
gap: var(--s-12);
|
||||
padding: var(--s-8) var(--s-10);
|
||||
border-left: var(--s-2) solid var(--color-up);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-up-soft);
|
||||
}
|
||||
|
||||
.heaven-check.is-pass {
|
||||
border-color: var(--color-down);
|
||||
background: var(--color-down-soft);
|
||||
}
|
||||
|
||||
.heaven-check small {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.heaven-manual-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--s-12);
|
||||
padding-top: var(--s-8);
|
||||
border-top: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.heaven-manual-grid .form-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.heaven-dialog-tabs {
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
margin-bottom: var(--s-12);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.heaven-dialog-tabs button {
|
||||
padding: var(--s-8) var(--s-12);
|
||||
border-bottom: var(--s-2) solid var(--c-transparent);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--c-transparent);
|
||||
}
|
||||
|
||||
.heaven-dialog-tabs button.active {
|
||||
color: var(--color-heaven);
|
||||
border-color: var(--color-heaven);
|
||||
}
|
||||
|
||||
.heaven-loading-stage {
|
||||
min-height: var(--s-480);
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
gap: var(--s-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heaven-loading-stage canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: var(--s-400);
|
||||
border-radius: var(--control-radius);
|
||||
}
|
||||
|
||||
.heaven-loading-stage p {
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
.heaven-answer {
|
||||
min-height: var(--s-240);
|
||||
color: var(--color-text);
|
||||
line-height: var(--s-24);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.heaven-history-list {
|
||||
display: grid;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.heaven-history-list button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-12);
|
||||
padding: var(--s-10) var(--s-12);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@keyframes heaven-turn { to { transform: rotate(360deg); } }
|
||||
@keyframes heaven-coin { 50% { transform: rotateY(180deg) translateY(calc(var(--s-8) * -1)); } }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
:root[data-theme="dark"] .heaven-page::before { left: 0; bottom: var(--shell-mobile-nav-height); }
|
||||
.heaven-mode-tabs button { text-align: center; }
|
||||
.heaven-mode-tabs span { display: none; }
|
||||
.heaven-trend-input { display: grid; gap: var(--s-8); }
|
||||
.heaven-trend-grid,
|
||||
.heaven-fortune-grid,
|
||||
.heaven-qi-layout,
|
||||
.heart-casting-layout,
|
||||
.heart-result { grid-template-columns: minmax(0, 1fr); }
|
||||
.heaven-manual-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.heaven-industry-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.heaven-check { grid-template-columns: minmax(0, 1fr); }
|
||||
.heaven-check small { text-align: left; }
|
||||
.heart-result { justify-items: center; text-align: center; }
|
||||
.heaven-loading-stage { min-height: var(--s-360); }
|
||||
.heaven-loading-stage canvas { min-height: var(--s-320); }
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.heaven-mode-tabs { gap: var(--s-2); }
|
||||
.heaven-mode-tabs button { padding-inline: var(--s-4); }
|
||||
.heaven-inline-control { display: grid; }
|
||||
.heaven-line-row { grid-template-columns: var(--s-40) var(--s-44) minmax(var(--s-64), 1fr) var(--s-44); }
|
||||
.heaven-line-row strong { display: none; }
|
||||
.heaven-hex-pair { gap: var(--s-8); }
|
||||
.heaven-industry-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.heart-coins { gap: var(--s-8); }
|
||||
.heart-coin { width: var(--s-64); height: var(--s-64); }
|
||||
}
|
||||
@@ -41,6 +41,10 @@
|
||||
--c-night-amber: #e2ad58;
|
||||
--c-night-amber-soft: #3d3220;
|
||||
--c-transparent: transparent;
|
||||
--c-gold-700: #8b6b2f;
|
||||
--c-gold-500: #c9a55c;
|
||||
--c-gold-100: #f4ead4;
|
||||
--c-night-star: #d8e6f4;
|
||||
|
||||
/* Primitive dimensions */
|
||||
--s-1: 1px;
|
||||
@@ -71,6 +75,13 @@
|
||||
--s-46: 46px;
|
||||
--s-56: 56px;
|
||||
--s-64: 64px;
|
||||
--s-80: 80px;
|
||||
--s-96: 96px;
|
||||
--s-120: 120px;
|
||||
--s-160: 160px;
|
||||
--s-180: 180px;
|
||||
--s-240: 240px;
|
||||
--s-480: 480px;
|
||||
--s-200: 200px;
|
||||
--s-260: 260px;
|
||||
--s-320: 320px;
|
||||
@@ -94,6 +105,8 @@
|
||||
--font-15: 15px;
|
||||
--font-17: 17px;
|
||||
--font-18: 18px;
|
||||
--font-24: 24px;
|
||||
--font-32: 32px;
|
||||
--weight-400: 400;
|
||||
--weight-500: 500;
|
||||
--weight-600: 600;
|
||||
@@ -146,6 +159,10 @@
|
||||
--color-warning: var(--c-amber-700);
|
||||
--color-warning-soft: var(--c-amber-050);
|
||||
--color-overlay: rgba(18, 20, 22, 0.46);
|
||||
--color-heaven: var(--c-gold-700);
|
||||
--color-heaven-bright: var(--c-gold-500);
|
||||
--color-heaven-soft: var(--c-gold-100);
|
||||
--color-heaven-star: var(--c-night-star);
|
||||
--shadow-card: var(--shadow-card-light);
|
||||
|
||||
/* Component tokens */
|
||||
@@ -187,5 +204,7 @@
|
||||
--color-warning: var(--c-night-amber);
|
||||
--color-warning-soft: var(--c-night-amber-soft);
|
||||
--color-overlay: rgba(0, 0, 0, 0.64);
|
||||
--color-heaven: var(--c-night-amber);
|
||||
--color-heaven-soft: var(--c-night-amber-soft);
|
||||
--shadow-card: var(--shadow-card-dark);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user