rebuild(stage-11): deliver deterministic heaven workflows

This commit is contained in:
leefer
2026-07-30 07:08:13 +08:00
parent aa3f02bd59
commit 35ae079de7
49 changed files with 7208 additions and 39 deletions
@@ -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>
+8
View File
@@ -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;
};
}
+3
View File
@@ -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>&nbsp;&nbsp;申万二级·<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 };
}
+63
View File
@@ -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);
}
+408
View File
@@ -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);
}