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,263 @@
1
+ // 入馆页:注册/登录 + 档案管理(列表/编辑/切换主档案/删除)
2
+ import { useState, useEffect } from 'react';
3
+ import { useNavigate, Link } from 'react-router-dom';
4
+ import { apiDivineHistory, apiDivineDetail, apiDivineDelete, downloadReport } from '@/services/api';
5
+ import ReportView from '@/components/ReportView';
6
+ import {
7
+ register, login, logout, currentUser, updateProfile,
8
+ addSampleProfile, updateSampleProfile, removeSampleProfile, promoteSampleProfile,
9
+ DEFAULT_PROFILE, type UserProfile, type NamedProfile,
10
+ } from '@/utils/userStore';
11
+ import { useConfirm } from '@/components/SongDialog';
12
+ import { syncRecordsFromServer } from '@/utils/recordStore';
13
+ import { syncProfileToServer } from '@/utils/userStore';
14
+ import ProfileForm from '@/components/ProfileForm';
15
+ import SongSelect from '@/components/SongSelect';
16
+
17
+ export default function AuthPage() {
18
+ const navigate = useNavigate();
19
+ const confirm = useConfirm();
20
+ const [, tick] = useState(0);
21
+ const refresh = () => tick(x => x + 1);
22
+ const user = currentUser();
23
+ const [mode, setMode] = useState<'login' | 'register' | 'profile'>(user ? 'profile' : 'register');
24
+ const [username, setUsername] = useState('');
25
+ const [password, setPassword] = useState('');
26
+ const [msg, setMsg] = useState('');
27
+ const [msgOk, setMsgOk] = useState(false);
28
+ // 编辑态:{ type: 'primary' } 或 { type: 'sample', id }
29
+ const [editing, setEditing] = useState<{ type: 'primary' } | { type: 'sample'; id: string } | null>(null); // 默认列表态
30
+
31
+ const submit = () => {
32
+ const r = mode === 'register' ? register(username, password) : login(username, password);
33
+ setMsg(r.message);
34
+ setMsgOk(r.ok);
35
+ if (r.ok) {
36
+ setMode('profile');
37
+ setEditing({ type: 'primary' });
38
+ navigate('/auth');
39
+ syncRecordsFromServer().catch(() => {});
40
+ if (r.user) syncProfileToServer(r.user.username, r.user.profile, r.user.samples).catch(() => {});
41
+ }
42
+ };
43
+
44
+ const saveProfile = (p: UserProfile) => {
45
+ if (!user) return;
46
+ if (editing?.type === 'primary') {
47
+ const u = updateProfile(p);
48
+ if (u) { setMsg('主档案已存'); setMsgOk(true); syncProfileToServer(u.username, u.profile, u.samples).catch(() => {}); }
49
+ } else if (editing?.type === 'sample') {
50
+ const u = updateSampleProfile(editing.id, p);
51
+ if (u) { setMsg('示例档案已存'); setMsgOk(true); syncProfileToServer(u.username, u.profile, u.samples).catch(() => {}); }
52
+ }
53
+ refresh();
54
+ };
55
+
56
+ const doLogout = () => { logout(); navigate('/'); };
57
+
58
+ // ===== 占卜历史(v6:起占自动入库,可查看/删除) =====
59
+ const [history, setHistory] = useState<{ divineId: string; artId: string; question: string | null; createdAt: number; hasReport: boolean; status: string }[]>([]);
60
+ const [histLoaded, setHistLoaded] = useState(false);
61
+ const [histDetail, setHistDetail] = useState<Record<string, unknown> | null>(null);
62
+ const refreshHistory = async () => {
63
+ if (!user) return;
64
+ try {
65
+ const r = await apiDivineHistory(user.username);
66
+ setHistory(r.list);
67
+ setHistLoaded(true);
68
+ } catch { /* 后端不可用时忽略 */ }
69
+ };
70
+ useEffect(() => { if (user) { refreshHistory(); } /* eslint-disable-next-line */ }, [user?.username]);
71
+ const openDetail = async (id: string) => {
72
+ if (!user) return;
73
+ const d = await apiDivineDetail(id, user.username);
74
+ if (d) setHistDetail(d);
75
+ };
76
+ const delHistory = async (id: string) => {
77
+ if (!user) return;
78
+ const ok = await confirm('除「此占」于史册?', { title: '慎问', confirmText: '除 之', danger: true });
79
+ if (ok && (await apiDivineDelete(id, user.username))) {
80
+ setHistory(h => h.filter(x => x.divineId !== id));
81
+ }
82
+ };
83
+
84
+ // 未登录:注册/登录
85
+ if (!user) {
86
+ return (
87
+ <div className="wrap page-compact" style={{ paddingBottom: 'var(--sp-6)', maxWidth: 560 }}>
88
+ <div className="section-eyebrow">入馆 · Gate</div>
89
+ <h2 className="section-title" style={{ marginTop: '.8rem' }}>观微入馆</h2>
90
+ <p className="section-note">录生辰档案,每日星运可依君而示;占问留痕,各归其馆。</p>
91
+ <div className="altar-panel" style={{ position: 'static', marginTop: 'var(--sp-4)' }}>
92
+ <div className="btn-row" style={{ marginBottom: 'var(--sp-3)' }}>
93
+ <button className={"btn-seal " + (mode === 'register' ? '' : 'btn-ghost')} style={{ fontSize: '.9rem', padding: '.45rem 1.2rem' }} onClick={() => setMode('register')}>注 册</button>
94
+ <button className={"btn-seal " + (mode === 'login' ? '' : 'btn-ghost')} style={{ fontSize: '.9rem', padding: '.45rem 1.2rem' }} onClick={() => setMode('login')}>登 录</button>
95
+ </div>
96
+ <div className="field"><label>名号</label><input className="input-line" placeholder="取一雅号" value={username} onChange={e => setUsername(e.target.value)} /></div>
97
+ <div className="field"><label>密语</label><input className="input-line" type="password" placeholder="四字以上" value={password} onChange={e => setPassword(e.target.value)} /></div>
98
+ <button className="btn-divine" onClick={submit}>{mode === 'register' ? '入 馆' : '入 座'}</button>
99
+ {msg && <p className={"mt-3 " + (msgOk ? 'tag-cool' : 'tag-hot')}>{msg}</p>}
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ // 编辑态:主档案或示例档案表单
106
+ if (editing) {
107
+ const target: NamedProfile | null = editing.type === 'sample'
108
+ ? (user.samples.find(s => s.id === editing.id) || null)
109
+ : null;
110
+ const title = editing.type === 'primary' ? '主档案(默认账号档案)' : '示例档案 · ' + (target?.name || '');
111
+ return (
112
+ <div className="wrap page-compact" style={{ paddingBottom: 'var(--sp-6)', maxWidth: 640 }}>
113
+ <div className="mh-row" style={{ marginBottom: 'var(--sp-3)' }}>
114
+ <button className="back-link" onClick={() => { setEditing(null); refresh(); }}>‹ 返档案列表</button>
115
+ <h2 className="page-title-compact">编 辑 · {title}</h2>
116
+ </div>
117
+ <div className="altar-panel" style={{ position: 'static', marginTop: 'var(--sp-4)' }}>
118
+ <ProfileForm initial={editing.type === 'primary' ? user.profile : target!.profile} onChange={saveProfile} />
119
+ <div className="btn-row mt-4">
120
+ <button className="btn-seal" style={{ fontSize: '.85rem', padding: '.45rem 1.2rem' }} onClick={() => { setEditing(null); refresh(); }}>存 档 完 成</button>
121
+ {editing.type === 'sample' && target && (
122
+ <button className="btn-seal btn-ghost" style={{ fontSize: '.85rem', padding: '.45rem 1.2rem' }} onClick={async () => {
123
+ const ok = await confirm('以「' + target.name + '」为主档案?原主档案将转示例。', { title: '慎问', confirmText: '设 为 主' });
124
+ if (ok) { promoteSampleProfile(target.id); setEditing({ type: 'primary' }); refresh(); }
125
+ }}>设为主档案</button>
126
+ )}
127
+ {editing.type === 'sample' && target && (
128
+ <button className="btn-seal btn-ghost" style={{ fontSize: '.85rem', padding: '.45rem 1.2rem' }} onClick={async () => {
129
+ const ok = await confirm('除「' + target.name + '」此档?', { title: '慎问', confirmText: '除 之', danger: true });
130
+ if (ok) { removeSampleProfile(target.id); setEditing(null); refresh(); }
131
+ }}>删 此 档</button>
132
+ )}
133
+ </div>
134
+ </div>
135
+ {msg && <p className={"mt-3 " + (msgOk ? 'tag-cool' : 'tag-hot')}>{msg}</p>}
136
+ </div>
137
+ );
138
+ }
139
+
140
+ // 列表态:主档案 + 示例档案卡片
141
+ return (
142
+ <div className="wrap page-compact" style={{ paddingBottom: 'var(--sp-6)', maxWidth: 760 }}>
143
+ <div className="section-eyebrow">入馆 · 档案</div>
144
+ <h2 className="section-title" style={{ marginTop: '.8rem' }}>吾之档案 · {user.username}</h2>
145
+ <p className="section-note">主档案唯一(默认账号档案);其余为示例档案,皆可编辑、切换。</p>
146
+
147
+ <div className="profile-card primary" onClick={() => setEditing({ type: 'primary' })} role="button" tabIndex={0}>
148
+ <div className="pc-badge">主</div>
149
+ <div className="pc-body">
150
+ <div className="pc-title">主档案 · {user.username}</div>
151
+ <div className="pc-info">{profileLine(user.profile)}</div>
152
+ </div>
153
+ <div className="pc-action">编 辑 ›</div>
154
+ </div>
155
+
156
+ {user.samples.length === 0 ? (
157
+ <div className="result-placeholder" style={{ marginTop: 'var(--sp-4)', minHeight: 140 }}>
158
+ <div className="glyph" style={{ fontSize: '2rem' }}>档</div>
159
+ <p>尚无示例档案 · 排盘时可「存为示例档案」</p>
160
+ </div>
161
+ ) : (
162
+ <div className="mt-4" style={{ display: 'grid', gap: '.8rem' }}>
163
+ {user.samples.map(s => (
164
+ <div className="profile-card" key={s.id} onClick={() => setEditing({ type: 'sample', id: s.id })} role="button" tabIndex={0}>
165
+ <div className="pc-badge sample">示</div>
166
+ <div className="pc-body">
167
+ <div className="pc-title">{s.name}</div>
168
+ <div className="pc-info">{profileLine(s.profile)}</div>
169
+ </div>
170
+ <div className="pc-action">编 辑 ›</div>
171
+ </div>
172
+ ))}
173
+ </div>
174
+ )}
175
+
176
+ {/* 占卜历史(起占自动入库;可查看详情、可删除) */}
177
+ <h3 className="panel-title" style={{ marginTop: 'var(--sp-6)' }}>占卜历史 · {user.username}</h3>
178
+ {!histLoaded ? (
179
+ <p className="tiny muted" style={{ marginTop: 'var(--sp-2)' }}>载录中……</p>
180
+ ) : history.length === 0 ? (
181
+ <div className="result-placeholder" style={{ marginTop: 'var(--sp-3)', minHeight: 110 }}>
182
+ <div className="glyph" style={{ fontSize: '1.8rem' }}>史</div>
183
+ <p>尚无占卜记录 · 起占后自动载入此册</p>
184
+ </div>
185
+ ) : (
186
+ <div className="mt-3" style={{ display: 'grid', gap: '.6rem' }}>
187
+ {history.map(h => (
188
+ <div className="profile-card" key={h.divineId} style={{ cursor: 'pointer' }} onClick={() => openDetail(h.divineId)} role="button" tabIndex={0}>
189
+ <div className="pc-badge sample">{artNameOf(h.artId)}</div>
190
+ <div className="pc-body">
191
+ <div className="pc-title">{h.question || '(未书,心念已至)'}</div>
192
+ <div className="pc-info">
193
+ {new Date(h.createdAt).toLocaleString('zh-CN')}
194
+ {' · ' + (h.hasReport ? '已有 AI 报告' : h.status === 'ai_poor' ? 'AI 未成' : '仅排盘')}
195
+ </div>
196
+ </div>
197
+ <div className="pc-action" onClick={(e) => { e.stopPropagation(); delHistory(h.divineId); }} role="button" tabIndex={0} style={{ color: 'var(--ink-faint)' }}>删 除</div>
198
+ </div>
199
+ ))}
200
+ </div>
201
+ )}
202
+
203
+ {/* 历史详情弹层:排盘数据 + AI 报告 */}
204
+ {histDetail && (
205
+ <div style={{ position: 'fixed', inset: 0, background: 'rgba(15,25,20,.55)', zIndex: 99, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }} onClick={() => setHistDetail(null)}>
206
+ <div className="altar-panel" style={{ position: 'static', maxWidth: 720, maxHeight: '82vh', overflowY: 'auto', background: 'var(--paper)' }} onClick={e => e.stopPropagation()}>
207
+ <div className="mh-row" style={{ marginBottom: 'var(--sp-2)' }}>
208
+ <h3 className="panel-title">占 卜 详 情</h3>
209
+ <button className="btn-seal btn-ghost" style={{ fontSize: '.78rem', padding: '.3rem .9rem' }} onClick={() => setHistDetail(null)}>关 闭</button>
210
+ </div>
211
+ <p className="tiny muted" style={{ marginBottom: 'var(--sp-3)' }}>
212
+ {artNameOf(String(histDetail.artId || ''))} · {new Date(Number(histDetail.createdAt)).toLocaleString('zh-CN')}
213
+ {histDetail.question ? ' · 所问:' + String(histDetail.question) : ''}
214
+ </p>
215
+ {histDetail.report ? (
216
+ <ReportView report={histDetail.report as any} artName={artNameOf(String(histDetail.artId || ''))} onExport={() => downloadReport(histDetail.report as any, artNameOf(String(histDetail.artId || '')), String(histDetail.question || ''))} />
217
+ ) : (
218
+ <div className="result-placeholder" style={{ minHeight: 120 }}>
219
+ <div className="glyph" style={{ fontSize: '1.8rem' }}>盘</div>
220
+ <p>此占尚未召 AI 成文,仅存排盘之象</p>
221
+ </div>
222
+ )}
223
+ {histDetail.resultRaw && (
224
+ <details className="mh-intro" style={{ marginTop: 'var(--sp-2)' }}>
225
+ <summary>排盘原始数据</summary>
226
+ <pre style={{ fontSize: '.72rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{JSON.stringify(histDetail.resultRaw, null, 1)}</pre>
227
+ </details>
228
+ )}
229
+ </div>
230
+ </div>
231
+ )}
232
+
233
+ <div className="btn-row mt-5">
234
+ <button className="btn-seal btn-ghost" style={{ fontSize: '.85rem', padding: '.4rem 1.2rem' }} onClick={doLogout}>离 馆</button>
235
+ <Link className="btn-seal" style={{ fontSize: '.85rem', padding: '.4rem 1.2rem' }} to="/">返 首 页</Link>
236
+ </div>
237
+ </div>
238
+ );
239
+ }
240
+
241
+ // 档案摘要行
242
+ function profileLine(p: UserProfile): string {
243
+ const parts = [p.birthDate + ' ' + (p.birthTime || '--:--')];
244
+ parts.push('时辰' + (p.birthHourIndex + 1));
245
+ parts.push(p.gender);
246
+ if (p.location) {
247
+ parts.push(p.location.province + p.location.city + (p.location.district || ''));
248
+ parts.push(p.location.lng.toFixed(2) + '°E ' + p.location.lat.toFixed(2) + '°N');
249
+ } else {
250
+ parts.push('未录地');
251
+ }
252
+ return parts.join(' · ');
253
+ }
254
+
255
+ // 术名映射(占卜历史展示用)
256
+ const ART_NAMES: Record<string, string> = {
257
+ bazi: '八字', ziwei: '紫微', astrology: '星盘',
258
+ qimen: '奇门', meihua: '梅花', liuyao: '六爻',
259
+ liuren: '大六壬', xiaoliuren: '小六壬', tarot: '塔罗',
260
+ };
261
+ function artNameOf(id: string): string {
262
+ return ART_NAMES[id] || id || '占';
263
+ }
@@ -0,0 +1,33 @@
1
+ import { Link } from 'react-router-dom';
2
+ import { Calendar, Construction, ArrowLeft } from 'lucide-react';
3
+
4
+ export default function BaZiPage() {
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
+ <Calendar 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
+ }
@@ -0,0 +1,120 @@
1
+ // 古籍典藏:列表(按术筛选/搜索)+ 详情(提要/背景/篇章/原文选段·古籍话术层)
2
+ import { useMemo, useState } from 'react';
3
+ import { Link, useParams } from 'react-router-dom';
4
+ import { CLASSICS, classicById, type ClassicBook } from '@core/data/classics';
5
+ import { ARTS } from '@/data/arts';
6
+ import SongSelect from '@/components/SongSelect';
7
+
8
+ export default function ClassicsPage() {
9
+ const { id } = useParams<{ id: string }>();
10
+ const [search, setSearch] = useState('');
11
+ const [artFilter, setArtFilter] = useState('all');
12
+
13
+ // 详情态
14
+ if (id) {
15
+ const book = classicById(id);
16
+ if (!book) {
17
+ return (
18
+ <div className="wrap page-compact" style={{ paddingTop: 'var(--sp-4)', textAlign: 'center' }}>
19
+ <div className="result-placeholder"><div className="glyph">佚</div><p>典籍未寻得 · 请返典藏再择</p></div>
20
+ <div className="mt-4"><Link className="btn-seal btn-ghost" to="/classics">返 典 藏</Link></div>
21
+ </div>
22
+ );
23
+ }
24
+ return <ClassicDetail book={book} />;
25
+ }
26
+
27
+ const filtered = useMemo(() => CLASSICS.filter(c => {
28
+ if (artFilter !== 'all' && !c.arts.includes(artFilter)) return false;
29
+ if (search && !c.title.includes(search) && !c.author.includes(search) && !c.summary.includes(search)) return false;
30
+ return true;
31
+ }), [artFilter, search]);
32
+
33
+ return (
34
+ <div className="wrap" style={{ paddingTop: 'var(--sp-7)', paddingBottom: 'var(--sp-6)' }}>
35
+ <div className="section-eyebrow">典藏 · Classics</div>
36
+ <h2 className="section-title" style={{ marginTop: '.8rem' }}>古籍典藏</h2>
37
+ <p className="section-note">溯术数之源流,录先贤之遗文。共 {CLASSICS.length} 部典籍,每部附原文选段、注译与出处。</p>
38
+
39
+ <div className="glass-filter" style={{ marginTop: 'var(--sp-3)' }}>
40
+ <div className="field-row" style={{ marginTop: 0, gridTemplateColumns: '1fr 1fr' }}>
41
+ <div className="field">
42
+ <input className="input-line" placeholder="搜书名 · 作者 · 提要" value={search} onChange={e => setSearch(e.target.value)} />
43
+ </div>
44
+ <div className="field">
45
+ <SongSelect value={artFilter} options={[{ value: 'all', label: '全部术数' }, ...ARTS.map(a => ({ value: a.id, label: a.name }))]} onChange={setArtFilter} />
46
+ </div>
47
+ </div>
48
+ </div>
49
+
50
+ <div className="mt-4" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(320px,1fr))', gap: 'var(--sp-3)' }}>
51
+ {filtered.map(b => <ClassicCard key={b.id} book={b} />)}
52
+ </div>
53
+ {filtered.length === 0 && (
54
+ <div className="result-placeholder mt-4"><div className="glyph">无</div><p>无典籍契合 · 请易词再寻</p></div>
55
+ )}
56
+ </div>
57
+ );
58
+ }
59
+
60
+ function ClassicCard({ book }: { book: ClassicBook }) {
61
+ return (
62
+ <Link to={'/classics/' + book.id} className="result-card" style={{ textDecoration: 'none', display: 'block', marginBottom: 0 }}>
63
+ <h3>{book.title}</h3>
64
+ <div className="tiny muted" style={{ marginBottom: '.4rem' }}>{book.author} · {book.era}</div>
65
+ <p className="result-text" style={{ marginTop: 0 }}>{book.summary}</p>
66
+ <div className="mt-2">
67
+ {book.arts.map(a => <span key={a} className="pill cool">{ARTS.find(x => x.id === a)?.name || a}</span>)}
68
+ </div>
69
+ </Link>
70
+ );
71
+ }
72
+
73
+ function ClassicDetail({ book }: { book: ClassicBook }) {
74
+ return (
75
+ <div className="wrap" style={{ paddingTop: 'var(--sp-7)', paddingBottom: 'var(--sp-6)', maxWidth: 880 }}>
76
+ <Link className="back-link" to="/classics">‹ 返典藏</Link>
77
+ <div className="module-kicker">典藏 · {book.era}</div>
78
+ <div className="module-title-row">
79
+ <h2 className="module-title"><span className="cn">{book.title.replace(/[《》]/g, '')}</span>{book.author}</h2>
80
+ </div>
81
+ <div className="mt-2" style={{ display: 'flex', gap: '.5rem', flexWrap: 'wrap' }}>
82
+ {book.arts.map(a => <Link key={a} className="pill cool" to={'/art/' + a}>观 {ARTS.find(x => x.id === a)?.name}</Link>)}
83
+ </div>
84
+
85
+ <div className="result-card mt-4">
86
+ <h3>提要</h3>
87
+ <p className="result-text">{book.summary}</p>
88
+ <h3 style={{ marginTop: 'var(--sp-3)' }}>成书背景</h3>
89
+ <p className="result-text">{book.background}</p>
90
+ </div>
91
+
92
+ <div className="result-card">
93
+ <h3>篇章</h3>
94
+ {book.chapters.map((c, i) => (
95
+ <p className="result-text" key={i}><strong>{c.title}</strong> —— {c.summary}</p>
96
+ ))}
97
+ </div>
98
+
99
+ <div className="result-card">
100
+ <h3>原文选段</h3>
101
+ {book.excerpts.map((e, i) => (
102
+ <div key={i} style={{ marginBottom: 'var(--sp-3)' }}>
103
+ <p className="tag-cool" style={{ letterSpacing: '.1em' }}><strong>{e.title}</strong></p>
104
+ <blockquote style={{
105
+ marginTop: '.5rem', padding: 'var(--sp-2) var(--sp-3)',
106
+ background: 'var(--paper-deep)', borderLeft: '3px solid var(--celadon)',
107
+ fontFamily: 'var(--font-song)', lineHeight: 2.2, color: 'var(--ink)',
108
+ }}>
109
+ {e.original}
110
+ </blockquote>
111
+ {e.translation && <p className="result-text mt-1"><strong>注译:</strong>{e.translation}</p>}
112
+ {e.annotation && <p className="result-text"><strong>参详:</strong>{e.annotation}</p>}
113
+ <p className="tiny muted mt-1">出处:{e.source}</p>
114
+ </div>
115
+ ))}
116
+ </div>
117
+ <p className="tiny muted">版本说明:{book.sourceNote}</p>
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,161 @@
1
+ // 演示模式(Demo Mode):无需后端 / 无需 API Key 的完整体验页
2
+ // 九术排盘用 shared/core 本地引擎在浏览器计算,AI 报告加载内置示例(静态 JSON)——GitHub Pages 上可直接体验
3
+ import { useState } from 'react';
4
+ import { Link } from 'react-router-dom';
5
+ import { baziCalc } from '@core/engine/bazi';
6
+ import { ziweiCalc } from '@core/engine/ziwei';
7
+ import { astrologyCalc } from '@core/engine/astrology';
8
+ import { qimenCalc } from '@core/engine/qimen';
9
+ import { meihuaCalc } from '@core/engine/meihua';
10
+ import { liuyaoCalc } from '@core/engine/liuyao';
11
+ import { liurenCalc } from '@core/engine/liuren';
12
+ import { xiaoliurenCalc } from '@core/engine/xiaoliuren';
13
+ import { tarotDraw } from '@core/engine/tarot';
14
+ import { allTarotSpreads } from '@core/data/tarotSpreads';
15
+ import { Solar } from 'lunar-typescript';
16
+ import { BaziResult } from '@/components/arts/BaziArt';
17
+ import { ZiweiResult } from '@/components/arts/ZiweiArt';
18
+ import { AstrologyResult } from '@/components/arts/AstrologyArt';
19
+ import { QimenResultView } from '@/components/arts/QimenArt';
20
+ import { MeihuaResult } from '@/components/arts/MeihuaArt';
21
+ import { LiuyaoResult } from '@/components/arts/LiuyaoArt';
22
+ import { LiurenResult } from '@/components/arts/LiurenArt';
23
+ import { XiaoliurenResult } from '@/components/arts/XiaoliurenArt';
24
+ import { TarotResult } from '@/components/arts/TarotArt';
25
+ import ReportView from '@/components/ReportView';
26
+ import { downloadReport, type AIReport } from '@/services/api';
27
+ import sample from '@/data/sample-report.json';
28
+ import Disclaimer from '@/components/Disclaimer';
29
+
30
+ // 虚构演示档案(中性,无真实个人信息)
31
+ const DEMO_BIRTH = { y: 1988, m: 6, d: 15, hourIndex: 6, time: '12:00', gender: '女' as const, location: { lng: 116.4, lat: 39.9, province: '北京市', city: '北京市', district: '东城区' } };
32
+
33
+ // 九术本地排盘(与后端 divine.ts 同一引擎、同一输入口径)
34
+ const ARTS: { id: string; name: string; glyph: string; calc: () => unknown }[] = [
35
+ { id: 'bazi', name: '四柱八字', glyph: '命', calc: () => baziCalc(DEMO_BIRTH) },
36
+ { id: 'ziwei', name: '紫微斗数', glyph: '星', calc: () => {
37
+ const lunar = Solar.fromYmd(DEMO_BIRTH.y, DEMO_BIRTH.m, DEMO_BIRTH.d).getLunar();
38
+ return ziweiCalc({ ganzhi: lunar.getYearInGanZhi(), month: Math.abs(lunar.getMonth()), day: lunar.getDay(), hour: DEMO_BIRTH.hourIndex, time: DEMO_BIRTH.time, location: DEMO_BIRTH.location, gender: DEMO_BIRTH.gender, birthYear: DEMO_BIRTH.y, solarDate: [DEMO_BIRTH.y, DEMO_BIRTH.m, DEMO_BIRTH.d] });
39
+ } },
40
+ { id: 'astrology', name: '古典星盘', glyph: '穹', calc: () => astrologyCalc(DEMO_BIRTH.y, DEMO_BIRTH.m, DEMO_BIRTH.d, 12, 0, DEMO_BIRTH.location.lng, DEMO_BIRTH.location.lat) },
41
+ { id: 'qimen', name: '奇门遁甲', glyph: '遁', calc: () => qimenCalc({ datetime: new Date() }) },
42
+ { id: 'meihua', name: '梅花易数', glyph: '梅', calc: () => meihuaCalc({ mode: 'time', n1: 3, n2: 5, n3: 7, now: new Date() }) },
43
+ { id: 'liuyao', name: '六爻', glyph: '爻', calc: () => liuyaoCalc(undefined, { y: new Date().getFullYear(), m: new Date().getMonth() + 1, d: new Date().getDate() }) },
44
+ { id: 'liuren', name: '大六壬', glyph: '课', calc: () => liurenCalc(new Date()) },
45
+ { id: 'xiaoliuren', name: '小六壬', glyph: '掌', calc: () => xiaoliurenCalc('time', new Date().getMonth() + 1, new Date().getDate(), new Date().getHours() % 12, 3, 5, 7) },
46
+ { id: 'tarot', name: '塔罗', glyph: '镜', calc: () => {
47
+ const spread = allTarotSpreads().find((x: any) => x.id === 'three') || allTarotSpreads()[0];
48
+ return { spread, cards: tarotDraw(3) };
49
+ } },
50
+ ];
51
+ const RESULT_VIEWS: Record<string, (data: unknown) => React.ReactElement> = {
52
+ bazi: d => <BaziResult data={d as any} />,
53
+ ziwei: d => <ZiweiResult data={d as any} />,
54
+ astrology: d => <AstrologyResult data={d as any} />,
55
+ qimen: d => <QimenResultView data={d as any} />,
56
+ meihua: d => <MeihuaResult data={d as any} />,
57
+ liuyao: d => <LiuyaoResult data={d as any} />,
58
+ liuren: d => <LiurenResult data={d as any} />,
59
+ xiaoliuren: d => <XiaoliurenResult data={d as any} />,
60
+ tarot: d => <TarotResult data={d as any} />,
61
+ };
62
+
63
+ export default function DemoPage() {
64
+ const [phase, setPhase] = useState<'idle' | 'casting' | 'done'>('idle');
65
+ const [report, setReport] = useState<AIReport | null>(null);
66
+ const [showRaw, setShowRaw] = useState(false);
67
+ const [artId, setArtId] = useState('bazi');
68
+ const [localResult, setLocalResult] = useState<unknown>(null);
69
+ const art = ARTS.find(a => a.id === artId)!;
70
+ const chart = baziCalc(DEMO_BIRTH);
71
+
72
+ const reset = () => {
73
+ setPhase('idle');
74
+ setReport(null);
75
+ setLocalResult(null);
76
+ setShowRaw(false);
77
+ };
78
+
79
+ const start = () => {
80
+ setPhase('casting');
81
+ setReport(null);
82
+ setLocalResult(null);
83
+ // 本地引擎排盘(纯浏览器计算,无需后端)
84
+ setTimeout(() => {
85
+ try {
86
+ setLocalResult(art.calc());
87
+ } catch (e) {
88
+ console.error('本地排盘失败', e);
89
+ }
90
+ if (artId === 'bazi') {
91
+ // 八字附带示例 AI 报告(演示解读效果)
92
+ setReport((sample as any).report as AIReport);
93
+ }
94
+ setPhase('done');
95
+ }, 900);
96
+ };
97
+
98
+ return (
99
+ <div className="wrap" style={{ paddingTop: 'var(--sp-6)' }}>
100
+ <section className="module-hero compact">
101
+ <div className="wrap">
102
+ <div className="mh-row">
103
+ <Link className="back-link" to="/">‹ 返册页</Link>
104
+ <h2 className="module-title"><span className="cn">体验演示</span><span className="mh-sub">无需注册 · 无需配置 · 点开即玩</span></h2>
105
+ <p className="mh-kicker">九种术数直接体验,八字附完整 AI 解读示例</p>
106
+ </div>
107
+ </div>
108
+ </section>
109
+
110
+ <div className="wrap" style={{ marginTop: 'var(--sp-4)' }}>
111
+ {phase === 'idle' && (
112
+ <div className="result-card" style={{ padding: 'var(--sp-5)' }}>
113
+ <p style={{ fontSize: '1.05rem', marginBottom: 'var(--sp-2)' }}>选一种术数,点一下就能看到排盘结果;八字还会附带一份完整的 AI 解读示例。</p>
114
+ <p className="tiny muted" style={{ marginBottom: 'var(--sp-2)' }}>示例档案:1988年6月15日 · 北京 · 女;其余术数按当前时刻起例</p>
115
+ <p className="tiny muted" style={{ marginBottom: 'var(--sp-3)' }}>三步即玩:① 选术数 → ② 点「开始体验」→ ③ 看结果</p>
116
+ <div className="art-picker" style={{ display: 'flex', flexWrap: 'wrap', gap: '.5rem', marginBottom: 'var(--sp-4)' }}>
117
+ {ARTS.map(a => (
118
+ <button key={a.id} onClick={() => setArtId(a.id)}
119
+ className={"btn-seal btn-ghost" + (a.id === artId ? ' art-active' : '')}
120
+ style={{ fontSize: '.85rem', padding: '.45rem 1rem', borderColor: a.id === artId ? 'var(--celadon-deep)' : undefined, color: a.id === artId ? 'var(--celadon-deep)' : undefined }}>
121
+ {a.glyph} {a.name}
122
+ </button>
123
+ ))}
124
+ </div>
125
+ <button className="btn-seal" style={{ fontSize: '1rem', padding: '.7rem 2.2rem' }} onClick={start}>开 始 体 验</button>
126
+ </div>
127
+ )}
128
+
129
+ {phase === 'casting' && (
130
+ <div className="result-card" style={{ textAlign: 'center', padding: 'var(--sp-6)' }}>
131
+ <div className="casting-ring"><div className="casting-glyph" style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-zm)', fontSize: '1.6rem', color: 'var(--celadon-deep)' }}>演</div></div>
132
+ <p>排盘推演中 · 报告将成</p>
133
+ </div>
134
+ )}
135
+
136
+ {phase === 'done' && (
137
+ <>
138
+ <div className="altar-result stagger">
139
+ {localResult && RESULT_VIEWS[artId]?.(localResult)}
140
+ {report && (
141
+ <div className="result-card mt-3">
142
+ <h3>AI 深度解读 <span className="tag-cool" style={{ marginLeft: '.4rem', fontSize: '.72rem' }}>示例报告</span></h3>
143
+ <ReportView report={report} artName="四柱八字" onExport={() => downloadReport(report, '四柱八字', '事业与感情运势如何')} />
144
+ </div>
145
+ )}
146
+ <div className="btn-row mt-3 no-print">
147
+ <button className="btn-seal btn-ghost" style={{ fontSize: '.9rem', padding: '.5rem 1.4rem' }} onClick={reset}>重 新 演 示</button>
148
+ <button className="btn-seal btn-ghost" style={{ fontSize: '.9rem', padding: '.5rem 1.4rem' }} onClick={() => setShowRaw(v => !v)}>{showRaw ? '隐 藏' : '查 看'}数据(开发者)</button>
149
+ <Link className="btn-seal btn-ghost" style={{ fontSize: '.9rem', padding: '.5rem 1.4rem', textDecoration: 'none' }} to="/auth">体验完整功能(存档 / AI 实时解读)→</Link>
150
+ </div>
151
+ {showRaw && report && (
152
+ <pre style={{ fontSize: '.68rem', lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all', background: 'rgba(251,249,243,.6)', border: '1px solid var(--line-soft)', borderRadius: 'var(--r-sm)', padding: 'var(--sp-3)', marginTop: 'var(--sp-3)' }}>{JSON.stringify(report, null, 1).slice(0, 4000)}</pre>
153
+ )}
154
+ </div>
155
+ <Disclaimer />
156
+ </>
157
+ )}
158
+ </div>
159
+ </div>
160
+ );
161
+ }