guanwei 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +202 -0
  3. package/README.md +247 -0
  4. package/dist/assets/index-CsOD5rSo.js +102 -0
  5. package/dist/assets/index-CsOD5rSo.js.map +1 -0
  6. package/dist/assets/index-DQx62TUr.css +1 -0
  7. package/dist/favicon.svg +12 -0
  8. package/dist/index.html +15 -0
  9. package/eslint.config.js +28 -0
  10. package/index.html +25 -0
  11. package/package.json +96 -0
  12. package/postcss.config.js +10 -0
  13. package/public/favicon.svg +12 -0
  14. package/scripts/guanwei +245 -0
  15. package/scripts/install.sh +79 -0
  16. package/scripts/release.sh +42 -0
  17. package/scripts/setup.bat +36 -0
  18. package/scripts/setup.sh +123 -0
  19. package/server/src/data/db.json +12059 -0
  20. package/server/src/data/guanwei.db +0 -0
  21. package/server/src/data/lifeContext.ts +423 -0
  22. package/server/src/data/spreads.ts +255 -0
  23. package/server/src/data/tarotCards.ts +2353 -0
  24. package/server/src/index.ts +71 -0
  25. package/server/src/routes/ai.ts +376 -0
  26. package/server/src/routes/divine.ts +140 -0
  27. package/server/src/routes/hour.ts +40 -0
  28. package/server/src/routes/tarot.ts +129 -0
  29. package/server/src/routes/users.ts +140 -0
  30. package/server/src/services/chartBrief.ts +131 -0
  31. package/server/src/services/divineStore.ts +188 -0
  32. package/server/src/services/hourInference.ts +150 -0
  33. package/server/src/services/llmProvider.ts +181 -0
  34. package/server/src/services/promptBuilder.ts +327 -0
  35. package/server/src/services/relativesCheck.ts +139 -0
  36. package/server/src/services/sixRelatives.ts +70 -0
  37. package/server/src/services/skills.ts +116 -0
  38. package/server/src/types/index.ts +104 -0
  39. package/server/src/utils/semanticAnalyzer.ts +555 -0
  40. package/server/src/utils/tarotEngine.ts +849 -0
  41. package/server/tsconfig.json +18 -0
  42. package/shared/core/data/classics.ts +276 -0
  43. package/shared/core/data/courses.ts +211 -0
  44. package/shared/core/data/duanyu.ts +148 -0
  45. package/shared/core/data/ganzhi.ts +53 -0
  46. package/shared/core/data/gua64.ts +93 -0
  47. package/shared/core/data/liuren.ts +18 -0
  48. package/shared/core/data/liuyao.ts +73 -0
  49. package/shared/core/data/qimen.ts +27 -0
  50. package/shared/core/data/region-full.ts +3 -0
  51. package/shared/core/data/region.ts +310 -0
  52. package/shared/core/data/tarotSpreads.ts +78 -0
  53. package/shared/core/data/xiaoliuren.ts +18 -0
  54. package/shared/core/data/ziwei.ts +97 -0
  55. package/shared/core/data/zodiac.ts +20 -0
  56. package/shared/core/engine/astrology.ts +169 -0
  57. package/shared/core/engine/bazi.ts +306 -0
  58. package/shared/core/engine/calendar.ts +127 -0
  59. package/shared/core/engine/daily.ts +133 -0
  60. package/shared/core/engine/liuren.ts +86 -0
  61. package/shared/core/engine/liuyao.ts +93 -0
  62. package/shared/core/engine/meihua.ts +87 -0
  63. package/shared/core/engine/qimen.ts +78 -0
  64. package/shared/core/engine/questionFit.ts +94 -0
  65. package/shared/core/engine/tarot.ts +69 -0
  66. package/shared/core/engine/trueSolarTime.ts +86 -0
  67. package/shared/core/engine/xiaoliuren.ts +19 -0
  68. package/shared/core/engine/ziwei.ts +217 -0
  69. package/shared/core/engine/ziweiInterpret.ts +161 -0
  70. package/shared/core/types.ts +206 -0
  71. package/src/App.tsx +69 -0
  72. package/src/arts/registry.ts +31 -0
  73. package/src/assets/react.svg +1 -0
  74. package/src/components/Backdrop.tsx +27 -0
  75. package/src/components/BambooArt.tsx +41 -0
  76. package/src/components/CalendarInput.tsx +131 -0
  77. package/src/components/DateInput.tsx +103 -0
  78. package/src/components/Disclaimer.tsx +12 -0
  79. package/src/components/Empty.tsx +8 -0
  80. package/src/components/ErrorBoundary.tsx +38 -0
  81. package/src/components/HomeSideAnchor.tsx +62 -0
  82. package/src/components/InkVein.tsx +10 -0
  83. package/src/components/LifeEventsInput.tsx +47 -0
  84. package/src/components/LocationPicker.tsx +117 -0
  85. package/src/components/Motes.tsx +65 -0
  86. package/src/components/Navigation.tsx +80 -0
  87. package/src/components/ProfileForm.tsx +40 -0
  88. package/src/components/ProfilePicker.tsx +40 -0
  89. package/src/components/QuestionFields.tsx +47 -0
  90. package/src/components/ReportView.tsx +211 -0
  91. package/src/components/ResultCard.tsx +40 -0
  92. package/src/components/SealButton.tsx +18 -0
  93. package/src/components/SiteFooter.tsx +17 -0
  94. package/src/components/SiteNav.tsx +103 -0
  95. package/src/components/SongDialog.tsx +64 -0
  96. package/src/components/SongSearchSelect.tsx +81 -0
  97. package/src/components/SongSelect.tsx +61 -0
  98. package/src/components/StarField.tsx +119 -0
  99. package/src/components/TarotCard.tsx +129 -0
  100. package/src/components/TimeShichenInput.tsx +29 -0
  101. package/src/components/arts/AstrologyArt.tsx +141 -0
  102. package/src/components/arts/BaziArt.tsx +269 -0
  103. package/src/components/arts/LiurenArt.tsx +87 -0
  104. package/src/components/arts/LiuyaoArt.tsx +111 -0
  105. package/src/components/arts/MeihuaArt.tsx +105 -0
  106. package/src/components/arts/QimenArt.tsx +86 -0
  107. package/src/components/arts/TarotArt.tsx +110 -0
  108. package/src/components/arts/XiaoliurenArt.tsx +93 -0
  109. package/src/components/arts/ZiweiArt.tsx +296 -0
  110. package/src/data/arts.ts +84 -0
  111. package/src/data/lifeContext.ts +423 -0
  112. package/src/data/sample-report.json +146 -0
  113. package/src/data/shichen.ts +21 -0
  114. package/src/data/spreads.ts +255 -0
  115. package/src/data/tarotCards.ts +2353 -0
  116. package/src/hooks/useAIInterpret.ts +76 -0
  117. package/src/hooks/useDivine.ts +39 -0
  118. package/src/hooks/useTheme.ts +29 -0
  119. package/src/index.css +190 -0
  120. package/src/lib/utils.ts +6 -0
  121. package/src/main.tsx +12 -0
  122. package/src/pages/AboutPage.tsx +25 -0
  123. package/src/pages/AcademyPage.tsx +128 -0
  124. package/src/pages/AstrologyPage.tsx +33 -0
  125. package/src/pages/AuthPage.tsx +263 -0
  126. package/src/pages/BaZiPage.tsx +33 -0
  127. package/src/pages/ClassicsPage.tsx +120 -0
  128. package/src/pages/DemoPage.tsx +161 -0
  129. package/src/pages/HistoryPage.tsx +133 -0
  130. package/src/pages/Home.tsx +179 -0
  131. package/src/pages/HomePage.tsx +174 -0
  132. package/src/pages/ModulePage.tsx +320 -0
  133. package/src/pages/SpreadEditor.tsx +230 -0
  134. package/src/pages/SpreadEditorPage.tsx +105 -0
  135. package/src/pages/SpreadLibrary.tsx +199 -0
  136. package/src/pages/SpreadLibraryPage.tsx +17 -0
  137. package/src/pages/TarotPage.tsx +738 -0
  138. package/src/pages/ZiWeiPage.tsx +33 -0
  139. package/src/services/api.ts +342 -0
  140. package/src/stores/useDivinationStore.ts +22 -0
  141. package/src/styles/song.css +1066 -0
  142. package/src/styles/tokens.css +60 -0
  143. package/src/types/index.ts +104 -0
  144. package/src/utils/astrologyCalc.ts +61 -0
  145. package/src/utils/comboEngine.ts +134 -0
  146. package/src/utils/dst.ts +24 -0
  147. package/src/utils/panTone.ts +206 -0
  148. package/src/utils/recordStore.ts +60 -0
  149. package/src/utils/semanticAnalyzer.ts +555 -0
  150. package/src/utils/spreadStorage.ts +32 -0
  151. package/src/utils/storage.ts +27 -0
  152. package/src/utils/tarotEngine.ts +849 -0
  153. package/src/utils/userStore.ts +234 -0
  154. package/src/vite-env.d.ts +1 -0
  155. package/tailwind.config.js +52 -0
  156. package/tsconfig.json +40 -0
  157. package/vite.config.ts +34 -0
@@ -0,0 +1,76 @@
1
+ // AI 解读状态机:idle → streaming(逐字累积)→ done(结构化段落)/ error
2
+ import { useCallback, useRef, useState } from 'react';
3
+ import { aiInterpretStream, type AIInterpretSection, type AIReport } from '@/services/api';
4
+
5
+ export type AIStatus = 'idle' | 'streaming' | 'done' | 'error';
6
+
7
+ export function useAIInterpret() {
8
+ const [status, setStatus] = useState<AIStatus>('idle');
9
+ const [text, setText] = useState('');
10
+ const [sections, setSections] = useState<AIInterpretSection[]>([]);
11
+ const [errorMsg, setErrorMsg] = useState('');
12
+ const [errorCode, setErrorCode] = useState('');
13
+ const [report, setReport] = useState<AIReport | null>(null);
14
+ const [truncated, setTruncated] = useState(false);
15
+ const [quality, setQuality] = useState<'ok' | 'poor'>('ok');
16
+ const abortRef = useRef<boolean>(false);
17
+ const doneRef = useRef<boolean>(false);
18
+ // 请求代际号:起占/重置会使在途请求失效,防止旧档案的流式结果回流覆盖新状态
19
+ const reqIdRef = useRef(0);
20
+
21
+ const interpret = useCallback(async (params: { artId: string; divineId?: string; question?: string; resultRaw?: unknown; semantic?: unknown; profile?: unknown; reportMode?: boolean; fit?: { suitable: boolean | 'partial'; reason: string; suggestion: string } }) => {
22
+ const myId = ++reqIdRef.current;
23
+ abortRef.current = false;
24
+ doneRef.current = false;
25
+ setStatus('streaming');
26
+ setText('');
27
+ setSections([]);
28
+ setReport(null);
29
+ setTruncated(false);
30
+ setQuality('ok');
31
+ setErrorMsg('');
32
+ setErrorCode('');
33
+ await aiInterpretStream(
34
+ params,
35
+ (char) => { if (reqIdRef.current === myId) setText(prev => prev + char); },
36
+ (secs, _full, rep, trunc, qual) => {
37
+ // 已重置/发起新请求 → 旧流结果作废
38
+ if (reqIdRef.current !== myId) return;
39
+ // done 事件与兜底都可能触发,仅第一次生效(flag 防重)
40
+ if (doneRef.current) return;
41
+ doneRef.current = true;
42
+ setSections(secs);
43
+ if (rep) setReport(rep);
44
+ else if (secs.length === 0 && !rep) setReport(null);
45
+ if (trunc) setTruncated(true);
46
+ if (qual === 'poor') setQuality('poor');
47
+ setStatus('done');
48
+ },
49
+ (code, message) => {
50
+ if (reqIdRef.current !== myId) return;
51
+ // 出错即锁定终态:流结束后若再触发兜底 onDone,也不得覆盖错误状态(否则会误把流式累积的原始文字当结果展示)
52
+ doneRef.current = true;
53
+ setErrorMsg(message || code);
54
+ setErrorCode(code);
55
+ setStatus('error');
56
+ },
57
+ );
58
+ }, []);
59
+
60
+ const reset = useCallback(() => {
61
+ // 使在途请求失效(旧档案的流式回调不再写入状态)
62
+ reqIdRef.current++;
63
+ abortRef.current = true;
64
+ doneRef.current = true;
65
+ setStatus('idle');
66
+ setText('');
67
+ setSections([]);
68
+ setErrorMsg('');
69
+ setErrorCode('');
70
+ setReport(null);
71
+ setTruncated(false);
72
+ setQuality('ok');
73
+ }, []);
74
+
75
+ return { status, text, sections, report, truncated, quality, errorMsg, errorCode, interpret, reset };
76
+ }
@@ -0,0 +1,39 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ export type DivineStatus = 'idle' | 'casting' | 'done' | 'error';
4
+
5
+ export function useDivine() {
6
+ const [status, setStatus] = useState<DivineStatus>('idle');
7
+ const [result, setResult] = useState<unknown>(null);
8
+ const [divineId, setDivineId] = useState<string | null>(null);
9
+ const [errorMsg, setErrorMsg] = useState('');
10
+
11
+ // fn 为异步计算(后端 /api/divine 调用),返回 { resultRaw, divineId }
12
+ const divine = useCallback(async (fn: () => Promise<{ resultRaw: unknown; divineId: string }>) => {
13
+ setStatus('casting');
14
+ setResult(null);
15
+ setDivineId(null);
16
+ setErrorMsg('');
17
+ // 凝神片刻(保留仪式感)
18
+ await new Promise(r => setTimeout(r, 420));
19
+ try {
20
+ const r = await fn();
21
+ setResult(r.resultRaw);
22
+ setDivineId(r.divineId);
23
+ setStatus('done');
24
+ } catch (e: any) {
25
+ console.error(e);
26
+ setErrorMsg(e?.message || '推演未应机');
27
+ setStatus('error');
28
+ }
29
+ }, []);
30
+
31
+ const reset = useCallback(() => {
32
+ setStatus('idle');
33
+ setResult(null);
34
+ setDivineId(null);
35
+ setErrorMsg('');
36
+ }, []);
37
+
38
+ return { status, result, divineId, errorMsg, divine, reset };
39
+ }
@@ -0,0 +1,29 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ type Theme = 'light' | 'dark';
4
+
5
+ export function useTheme() {
6
+ const [theme, setTheme] = useState<Theme>(() => {
7
+ const savedTheme = localStorage.getItem('theme') as Theme;
8
+ if (savedTheme) {
9
+ return savedTheme;
10
+ }
11
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
12
+ });
13
+
14
+ useEffect(() => {
15
+ document.documentElement.classList.remove('light', 'dark');
16
+ document.documentElement.classList.add(theme);
17
+ localStorage.setItem('theme', theme);
18
+ }, [theme]);
19
+
20
+ const toggleTheme = () => {
21
+ setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
22
+ };
23
+
24
+ return {
25
+ theme,
26
+ toggleTheme,
27
+ isDark: theme === 'dark'
28
+ };
29
+ }
package/src/index.css ADDED
@@ -0,0 +1,190 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
2
+
3
+ @tailwind base;
4
+ @tailwind components;
5
+ @tailwind utilities;
6
+
7
+ @layer base {
8
+ html {
9
+ scroll-behavior: smooth;
10
+ overflow-x: hidden;
11
+ }
12
+
13
+ body {
14
+ @apply bg-void text-stardust font-body;
15
+ overflow-x: hidden;
16
+ }
17
+
18
+ ::selection {
19
+ @apply bg-ancient/30 text-stardust;
20
+ }
21
+
22
+ ::-webkit-scrollbar {
23
+ width: 6px;
24
+ }
25
+
26
+ ::-webkit-scrollbar-track {
27
+ @apply bg-void;
28
+ }
29
+
30
+ ::-webkit-scrollbar-thumb {
31
+ @apply bg-ancient/30 rounded-full;
32
+ }
33
+
34
+ ::-webkit-scrollbar-thumb:hover {
35
+ @apply bg-ancient/50;
36
+ }
37
+ }
38
+
39
+ @layer components {
40
+ .glass-panel {
41
+ @apply bg-abyss/60 backdrop-blur-md border border-ancient/20 rounded-lg;
42
+ }
43
+
44
+ .gold-border {
45
+ @apply border border-ancient/40 hover:border-ancient/80 transition-all duration-500;
46
+ }
47
+
48
+ .text-glow {
49
+ text-shadow: 0 0 10px rgba(201, 162, 39, 0.5), 0 0 20px rgba(201, 162, 39, 0.3);
50
+ }
51
+
52
+ .card-3d {
53
+ transform-style: preserve-3d;
54
+ perspective: 1000px;
55
+ }
56
+
57
+ .card-face {
58
+ backface-visibility: hidden;
59
+ }
60
+
61
+ .card-back {
62
+ transform: rotateY(180deg);
63
+ }
64
+
65
+ .mystic-gradient {
66
+ background: linear-gradient(135deg, rgba(26,26,46,0.9) 0%, rgba(10,10,15,0.95) 100%);
67
+ }
68
+
69
+ .shimmer-text {
70
+ background: linear-gradient(90deg, #c9a227 0%, #ffd700 50%, #c9a227 100%);
71
+ background-size: 200% auto;
72
+ -webkit-background-clip: text;
73
+ -webkit-text-fill-color: transparent;
74
+ animation: shimmer 3s linear infinite;
75
+ }
76
+ }
77
+
78
+ @layer utilities {
79
+ .perspective-1000 {
80
+ perspective: 1000px;
81
+ }
82
+
83
+ .preserve-3d {
84
+ transform-style: preserve-3d;
85
+ }
86
+
87
+ .backface-hidden {
88
+ backface-visibility: hidden;
89
+ }
90
+
91
+ .rotate-y-180 {
92
+ transform: rotateY(180deg);
93
+ }
94
+ }
95
+
96
+ @keyframes shimmer {
97
+ 0% { background-position: 200% center; }
98
+ 100% { background-position: -200% center; }
99
+ }
100
+
101
+ @keyframes fade-in {
102
+ from {
103
+ opacity: 0;
104
+ transform: translateY(10px);
105
+ }
106
+ to {
107
+ opacity: 1;
108
+ transform: translateY(0);
109
+ }
110
+ }
111
+
112
+ @keyframes float {
113
+ 0%, 100% {
114
+ transform: translateY(0px);
115
+ }
116
+ 50% {
117
+ transform: translateY(-10px);
118
+ }
119
+ }
120
+
121
+ @keyframes pulse-glow {
122
+ 0%, 100% {
123
+ box-shadow: 0 0 5px rgba(201, 162, 39, 0.3);
124
+ }
125
+ 50% {
126
+ box-shadow: 0 0 20px rgba(201, 162, 39, 0.6);
127
+ }
128
+ }
129
+
130
+ @keyframes typing-cursor {
131
+ 0%, 100% {
132
+ opacity: 1;
133
+ }
134
+ 50% {
135
+ opacity: 0;
136
+ }
137
+ }
138
+
139
+ @layer utilities {
140
+ .animate-fade-in {
141
+ animation: fade-in 0.5s ease-out forwards;
142
+ }
143
+
144
+ .animate-float {
145
+ animation: float 3s ease-in-out infinite;
146
+ }
147
+
148
+ .animate-pulse-glow {
149
+ animation: pulse-glow 2s ease-in-out infinite;
150
+ }
151
+
152
+ .animate-typing-cursor {
153
+ animation: typing-cursor 1s step-end infinite;
154
+ }
155
+ }
156
+
157
+ /* ===== 打印 / 存为 PDF:仅打印报告区 ===== */
158
+ @media print {
159
+ /* 只打印报告区:display:none 释放布局空间(visibility 会保留占位导致空白页) */
160
+ body { background: #fff !important; }
161
+ header, footer, nav, aside, .site-head, .site-nav, .back-link,
162
+ .module-hero, .mh-intro, .mh-row, .altar-panel, .fit-card, .fit-note,
163
+ .btn-row, .ai-streaming, .ai-error, .result-placeholder, .disclaimer,
164
+ .d-title, .motes, .ink-vein, .bamboo, .cal-grid, .profile-card, .no-print { display: none !important; }
165
+ main { display: block !important; padding: 0 !important; }
166
+ .wrap { max-width: none !important; padding: 0 !important; }
167
+ .altar { display: block !important; }
168
+ .altar-result { width: 100% !important; max-width: none !important; }
169
+ .report-view { color: #000 !important; background: #fff !important; }
170
+ /* 卡片允许跨页(避免整卡跳页在前页留白);仅小元素防截断 */
171
+ .report-view .result-card {
172
+ break-inside: auto;
173
+ border: 1px solid #ddd !important;
174
+ background: #fff !important;
175
+ box-shadow: none !important;
176
+ color: #000 !important;
177
+ margin-bottom: 6mm;
178
+ }
179
+ .report-view .result-card h3 { break-after: avoid; }
180
+ .report-view .domain-cell, .report-view li, .report-view .traits-grid .trait { break-inside: avoid; }
181
+ .report-view .result-title { break-after: avoid; }
182
+ .report-view .result-text, .report-view p, .report-view li, .report-view h3 { color: #000 !important; }
183
+ .report-view .domain-name { color: #555 !important; }
184
+ @page { margin: 12mm; }
185
+ }
186
+
187
+ @keyframes pulse-soft {
188
+ 0%, 100% { opacity: .55; }
189
+ 50% { opacity: 1; }
190
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,12 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import App from './App'
4
+ import './index.css'
5
+ import './styles/tokens.css'
6
+ import './styles/song.css'
7
+
8
+ createRoot(document.getElementById('root')!).render(
9
+ <StrictMode>
10
+ <App />
11
+ </StrictMode>,
12
+ )
@@ -0,0 +1,25 @@
1
+ // 缘起页(古风提示层文案)
2
+ import SealButton from '@/components/SealButton';
3
+
4
+ import BambooArt from '@/components/BambooArt';
5
+
6
+ export default function AboutPage() {
7
+ return (
8
+ <div className="page-decor" style={{ paddingTop: 'var(--sp-7)', paddingBottom: 'var(--sp-6)' }}>
9
+ <BambooArt />
10
+ <div className="wrap">
11
+ <div className="section-eyebrow">缘起 · Origin</div>
12
+ <h2 className="section-title" style={{ marginTop: '.8rem', fontSize: 'var(--fs-2xl)' }}>观微缘起</h2>
13
+ <div className="result-text mt-4" style={{ maxWidth: '70ch' }}>
14
+ <p>以纸墨为衣、以留白为韵。玄学问道,贵在内敛沉静——「致虚极,守静笃」,此与观照自身的功夫,本出一源。</p>
15
+ <p>本馆以纸墨之雅为衣,纳九派术数为里——<strong>四柱八字</strong>论命之经纬,<strong>紫微斗数</strong>布星之宫垣,<strong>奇门遁甲</strong>演时之局盘,<strong>梅花易数</strong>观物之象数,<strong>六爻</strong>承蓍龟之遗法,<strong>大六壬</strong>推天地人三传,<strong>小六壬</strong>藏掐指之玄机,<strong>星盘</strong>应西洋之天穹,<strong>塔罗</strong>照潜识之幽微。</p>
16
+ <p>术数有中西,观照本无二。愿君于这一方纸墨之间,静得片刻,观微知著。</p>
17
+ </div>
18
+ <div className="separator">· · ·</div>
19
+ <div className="center">
20
+ <SealButton to="/" onClick={() => {}} ghost>启程问占</SealButton>
21
+ </div>
22
+ </div>
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,128 @@
1
+ // 观微学馆:课程列表(分组)+ 课程详情(章节 + 练习自检)
2
+ import { useState } from 'react';
3
+ import { Link, useParams } from 'react-router-dom';
4
+ import { COURSES, courseById, type Course } from '@core/data/courses';
5
+ import { classicById } from '@core/data/classics';
6
+ import { ARTS } from '@/data/arts';
7
+
8
+ const artName = (art: string) => {
9
+ if (art === 'foundation') return '入门基础';
10
+ if (art === 'dao') return '占问之道';
11
+ return ARTS.find(a => a.id === art)?.name || art;
12
+ }
13
+
14
+ export default function AcademyPage() {
15
+ const { id } = useParams<{ id: string }>();
16
+
17
+ if (id) {
18
+ const course = courseById(id);
19
+ if (!course) {
20
+ return (
21
+ <div className="wrap page-compact" style={{ paddingTop: 'var(--sp-4)', textAlign: 'center' }}>
22
+ <div className="result-placeholder"><div className="glyph">佚</div><p>课程未寻得 · 请返学馆再择</p></div>
23
+ <div className="mt-4"><Link className="btn-seal btn-ghost" to="/academy">返 学 馆</Link></div>
24
+ </div>
25
+ );
26
+ }
27
+ return <CourseDetail course={course} />;
28
+ }
29
+
30
+ const groups = ['foundation', 'dao', ...ARTS.map(a => a.id)];
31
+ return (
32
+ <div className="wrap" style={{ paddingTop: 'var(--sp-7)', paddingBottom: 'var(--sp-6)' }}>
33
+ <div className="section-eyebrow">学馆 · Academy</div>
34
+ <h2 className="section-title" style={{ marginTop: '.8rem' }}>观微学馆</h2>
35
+ <p className="section-note">入门基础 · 各术入门 · 占问之道,循序渐进。共 {COURSES.length} 门课程。</p>
36
+ {groups.map(g => {
37
+ const list = COURSES.filter(c => c.art === g);
38
+ if (!list.length) return null;
39
+ return (
40
+ <div key={g} className="mt-5">
41
+ <h3 className="tag-cool" style={{ letterSpacing: '.25em', marginBottom: 'var(--sp-2)' }}>{artName(g)}</h3>
42
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(300px,1fr))', gap: 'var(--sp-3)' }}>
43
+ {list.map(c => (
44
+ <Link key={c.id} to={'/academy/' + c.id} className="result-card" style={{ textDecoration: 'none', display: 'block', marginBottom: 0 }}>
45
+ <h3>{c.title}</h3>
46
+ <p className="result-text" style={{ marginTop: 0 }}>{c.summary}</p>
47
+ <div className="mt-2">
48
+ <span className="pill">{c.level}</span>
49
+ <span className="pill cool">{c.chapters.length} 章 · {c.exercises.length} 练</span>
50
+ </div>
51
+ </Link>
52
+ ))}
53
+ </div>
54
+ </div>
55
+ );
56
+ })}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ function CourseDetail({ course }: { course: Course }) {
62
+ const [answers, setAnswers] = useState<Record<number, string>>({});
63
+ const [checked, setChecked] = useState<Record<number, boolean>>({});
64
+ const check = (i: number) => setChecked(prev => ({ ...prev, [i]: true }));
65
+ return (
66
+ <div className="wrap" style={{ paddingTop: 'var(--sp-7)', paddingBottom: 'var(--sp-6)', maxWidth: 880 }}>
67
+ <Link className="back-link" to="/academy">‹ 返学馆</Link>
68
+ <div className="module-kicker">学馆 · {artName(course.art)}</div>
69
+ <div className="module-title-row">
70
+ <h2 className="module-title"><span className="cn">{course.title}</span>{course.level}</h2>
71
+ </div>
72
+ <p className="module-intro">{course.summary}</p>
73
+
74
+ <div className="result-card mt-4">
75
+ <h3>讲义</h3>
76
+ {course.chapters.map((ch, i) => (
77
+ <div key={i} style={{ marginBottom: 'var(--sp-2)' }}>
78
+ <p className="tag-cool"><strong>{i + 1}. {ch.title}</strong></p>
79
+ <p className="result-text">{ch.body}</p>
80
+ </div>
81
+ ))}
82
+ </div>
83
+
84
+ {course.exercises.length > 0 && (
85
+ <div className="result-card">
86
+ <h3>小试牛刀</h3>
87
+ {course.exercises.map((ex, i) => {
88
+ const isRight = checked[i] && (answers[i] || '').trim() === ex.answer;
89
+ return (
90
+ <div key={i} style={{ marginBottom: 'var(--sp-3)' }}>
91
+ <p className="result-text"><strong>练习 {i + 1}:</strong>{ex.prompt}</p>
92
+ <div className="btn-row" style={{ gap: '.6rem', marginTop: '.4rem' }}>
93
+ <input
94
+ className="input-line"
95
+ style={{ maxWidth: 320 }}
96
+ placeholder="书答案于此"
97
+ value={answers[i] || ''}
98
+ onChange={e => { setAnswers(prev => ({ ...prev, [i]: e.target.value })); setChecked(prev => ({ ...prev, [i]: false })); }}
99
+ disabled={checked[i]}
100
+ />
101
+ {!checked[i] && <button className="btn-seal" style={{ fontSize: '.8rem', padding: '.35rem 1rem' }} onClick={() => check(i)}>自 检</button>}
102
+ </div>
103
+ {checked[i] && (
104
+ <p className={"mt-1 " + (isRight ? 'tag-cool' : 'tag-hot')}>
105
+ <strong>{isRight ? '对 · 甚善' : '未中 · 再思'} </strong>
106
+ {isRight ? ex.explain : '参考答案:' + ex.answer + '。' + ex.explain}
107
+ </p>
108
+ )}
109
+ </div>
110
+ );
111
+ })}
112
+ </div>
113
+ )}
114
+
115
+ {course.relatedClassics.length > 0 && (
116
+ <div className="result-card">
117
+ <h3>延伸典藏</h3>
118
+ <div style={{ display: 'flex', gap: '.5rem', flexWrap: 'wrap' }}>
119
+ {course.relatedClassics.map(id2 => {
120
+ const b = classicById(id2);
121
+ return b ? <Link key={id2} className="pill" to={'/classics/' + id2}>{b.title}</Link> : null;
122
+ })}
123
+ </div>
124
+ </div>
125
+ )}
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,33 @@
1
+ import { Link } from 'react-router-dom';
2
+ import { CircleDot, Construction, ArrowLeft } from 'lucide-react';
3
+
4
+ export default function AstrologyPage() {
5
+ return (
6
+ <div className="relative min-h-screen pt-20 pb-16 px-4 flex items-center justify-center">
7
+ <div className="max-w-2xl mx-auto text-center">
8
+ <div className="glass-panel p-12">
9
+ <CircleDot className="w-16 h-16 text-ancient/40 mx-auto mb-6" />
10
+ <h1 className="font-display text-3xl text-ancient tracking-wider mb-4">
11
+ 星盘占星
12
+ </h1>
13
+ <p className="text-stardust/60 mb-2">西方占星之术,解读行星语言</p>
14
+ <div className="flex items-center justify-center gap-2 text-mystic/60 mb-8">
15
+ <Construction className="w-4 h-4" />
16
+ <span className="text-sm">功能开发中</span>
17
+ </div>
18
+ <p className="text-stardust/40 text-sm mb-8 leading-relaxed">
19
+ 星盘占星通过计算出生时各行星在黄道十二宫的位置,绘制出个人出生星盘。
20
+ 此功能将集成完整的星盘绘制、相位分析和推运系统。
21
+ </p>
22
+ <Link
23
+ to="/tarot"
24
+ className="inline-flex items-center gap-2 px-6 py-2 border border-ancient/50 text-ancient hover:bg-ancient/10 transition-all rounded-sm text-sm"
25
+ >
26
+ <ArrowLeft className="w-4 h-4" />
27
+ 先体验塔罗占卜
28
+ </Link>
29
+ </div>
30
+ </div>
31
+ </div>
32
+ );
33
+ }