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,38 @@
1
+ // 错误边界:任何页面渲染崩溃时显示古风占位,避免白屏
2
+ import { Component } from 'react';
3
+ import type { ReactNode } from 'react';
4
+ import { Link } from 'react-router-dom';
5
+
6
+ interface State { hasError: boolean; message: string }
7
+
8
+ export default class ErrorBoundary extends Component<{ children: ReactNode }, State> {
9
+ state: State = { hasError: false, message: '' };
10
+
11
+ static getDerivedStateFromError(e: unknown): State {
12
+ return { hasError: true, message: e instanceof Error ? e.message : '未知之虞' };
13
+ }
14
+
15
+ componentDidCatch(e: unknown) {
16
+ console.error('[观微] 页面异常:', e);
17
+ }
18
+
19
+ render() {
20
+ if (this.state.hasError) {
21
+ return (
22
+ <div className="wrap" style={{ paddingTop: 'var(--sp-8)', textAlign: 'center' }}>
23
+ <div className="result-placeholder">
24
+ <div className="glyph">滞</div>
25
+ <p>此页一时未应机 · 请返首页再试</p>
26
+ <p className="tiny muted" style={{ marginTop: '.4rem' }}>{this.state.message}</p>
27
+ </div>
28
+ <div className="mt-4">
29
+ <Link className="btn-seal" to="/" onClick={() => this.setState({ hasError: false })}>
30
+ <span className="sb-label">返 首 页</span>
31
+ </Link>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
36
+ return this.props.children;
37
+ }
38
+ }
@@ -0,0 +1,62 @@
1
+ // 首页左侧锚点目录(竖向固定):观微之始 / 今日黄历 / 今日星运 / 九术册页 / 观微三法
2
+ import { useEffect, useState } from 'react';
3
+ import { useLocation } from 'react-router-dom';
4
+
5
+ const ANCHORS = [
6
+ { id: 'top', label: '观微之始' },
7
+ { id: 'today', label: '今日黄历' },
8
+ { id: 'home-arts', label: '九术册页' },
9
+ { id: 'home-fa', label: '观微三法' },
10
+ ];
11
+
12
+ export default function HomeSideAnchor() {
13
+ const [active, setActive] = useState('top');
14
+ const location = useLocation();
15
+
16
+ // 跨页跳转带锚点时滚动
17
+ useEffect(() => {
18
+ const target = (location.state as { scrollTo?: string } | null)?.scrollTo;
19
+ if (target) {
20
+ requestAnimationFrame(() => {
21
+ if (target === 'top') window.scrollTo({ top: 0, behavior: 'smooth' });
22
+ else document.getElementById(target)?.scrollIntoView({ behavior: 'smooth' });
23
+ });
24
+ }
25
+ }, [location.state]);
26
+
27
+ // 滚动跟随
28
+ useEffect(() => {
29
+ const onScroll = () => {
30
+ const pos = window.scrollY + 140;
31
+ let cur = 'top';
32
+ for (const a of ANCHORS) {
33
+ const el = document.getElementById(a.id);
34
+ if (el && el.offsetTop <= pos) cur = a.id;
35
+ }
36
+ setActive(cur);
37
+ };
38
+ window.addEventListener('scroll', onScroll, { passive: true });
39
+ onScroll();
40
+ return () => window.removeEventListener('scroll', onScroll);
41
+ }, []);
42
+
43
+ const go = (id: string) => {
44
+ if (id === 'top') window.scrollTo({ top: 0, behavior: 'smooth' });
45
+ else document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
46
+ };
47
+
48
+ return (
49
+ <nav className="side-anchor" aria-label="首页目录">
50
+ {ANCHORS.map(a => (
51
+ <button
52
+ key={a.id}
53
+ className={active === a.id ? 'active' : ''}
54
+ onClick={() => go(a.id)}
55
+ >
56
+ <span className="dot" />
57
+ {a.label}
58
+ </button>
59
+ ))}
60
+ </nav>
61
+ );
62
+ }
@@ -0,0 +1,10 @@
1
+ // 韵脚暗纹:右侧竖排淡墨诗句 + 流动云纹暗纹层 + 朱砂小印
2
+ export default function InkVein() {
3
+ return (
4
+ <div id="ink-vein" aria-hidden="true">
5
+ <div className="vein-lines" />
6
+ <div className="vein-poem">静以观微 · 虚以纳象 · 诚以问占</div>
7
+ <div className="vein-seal">观微</div>
8
+ </div>
9
+ );
10
+ }
@@ -0,0 +1,47 @@
1
+ // 人生经历校准输入:记录命主已知人生事件(年份 + 事件),AI 解读时呼应并不得矛盾
2
+ import { useState } from 'react';
3
+
4
+ export interface LifeEvent { year: number; text: string }
5
+
6
+ interface Props {
7
+ value: LifeEvent[];
8
+ onChange: (events: LifeEvent[]) => void;
9
+ }
10
+
11
+ const toText = (evs: LifeEvent[]) => evs.map(e => e.year + ' ' + e.text).join('\n');
12
+
13
+ const parseText = (s: string): LifeEvent[] => s
14
+ .split('\n').map(l => l.trim()).filter(Boolean)
15
+ .map(l => {
16
+ const m = /^(\d{4})\s*(.*)$/.exec(l);
17
+ return m ? { year: Number(m[1]), text: (m[2] || '重要事件').slice(0, 40) } : null;
18
+ })
19
+ .filter((e): e is LifeEvent => !!e && e.year >= 1900 && e.year <= 2100)
20
+ .sort((a, b) => a.year - b.year);
21
+
22
+ export default function LifeEventsInput({ value, onChange }: Props) {
23
+ const [open, setOpen] = useState(false);
24
+ const [draft, setDraft] = useState(toText(value));
25
+
26
+ const commit = () => onChange(parseText(draft));
27
+
28
+ return (
29
+ <div className="life-events" style={{ marginTop: '.4rem' }}>
30
+ <button type="button" className="btn-seal btn-ghost" style={{ fontSize: '.82rem', padding: '.4rem 1rem' }} onClick={() => setOpen(v => !v)}>
31
+ {open ? '收 起' : '📌 补充人生经历(解读校准)'}
32
+ {value.length > 0 && <span className="tag-cool" style={{ marginLeft: '.4rem' }}>已录 {value.length} 条</span>}
33
+ </button>
34
+ {open && (
35
+ <div style={{ marginTop: '.6rem' }}>
36
+ <p className="tiny muted" style={{ marginBottom: '.3rem' }}>每行一条:「年份 关键事件」——AI 解读将在对应流年处呼应这些经历,且不与它们矛盾(3-10 条为宜)</p>
37
+ <textarea className="input-line" rows={5} value={draft} onChange={e => setDraft(e.target.value)}
38
+ placeholder={'2004 家庭变故\n2016 换城市\n2020 换工作\n2022 结婚\n2024 健康问题'} />
39
+ <div className="btn-row" style={{ marginTop: '.4rem' }}>
40
+ <button className="btn-seal" style={{ fontSize: '.82rem', padding: '.35rem 1rem' }} onClick={commit}>保 存 校 准</button>
41
+ {value.length > 0 && <span className="tiny muted" style={{ alignSelf: 'center' }}>共 {value.length} 条,随排盘一同提交</span>}
42
+ </div>
43
+ </div>
44
+ )}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,117 @@
1
+ // 出生地点选择器:省 → 市 → 区 三级级联 + 经纬度/真太阳时预览
2
+ import { useEffect, useState } from 'react';
3
+ import { allProvinces, citiesOf, districtsOf, resolveLocation, type GeoSelection } from '@core/data/region';
4
+ import SongSearchSelect from '@/components/SongSearchSelect';
5
+ import { trueSolarTime } from '@core/engine/trueSolarTime';
6
+
7
+ interface Props {
8
+ value: GeoSelection | null;
9
+ onChange: (v: GeoSelection | null) => void;
10
+ // 可选的时刻预览(真太阳时校正显示;hourIndex 0-11)
11
+ previewHourIndex?: number;
12
+ }
13
+
14
+ const HOUR_LABELS = ['子时', '丑时', '寅时', '卯时', '辰时', '巳时', '午时', '未时', '申时', '酉时', '戌时', '亥时'];
15
+
16
+ export default function LocationPicker({ value, onChange, previewHourIndex }: Props) {
17
+ const [province, setProvince] = useState(value?.province ?? '');
18
+ const [city, setCity] = useState(value?.city ?? '');
19
+ const [district, setDistrict] = useState(value?.district ?? '');
20
+ // 手动经纬度(乡镇/村兜底,优先于行政区划)
21
+ const [manual, setManual] = useState(false);
22
+ const [lngTxt, setLngTxt] = useState('');
23
+ const [latTxt, setLatTxt] = useState('');
24
+
25
+ // 外部 value 变化同步(档案切换/回显):有行政区划则回显省市区;无省名(手动定位来源)则回显手动坐标
26
+ useEffect(() => {
27
+ if (!value) return;
28
+ if (value.province && value.district !== '手动定位') {
29
+ setProvince(value.province);
30
+ setCity(value.city || '');
31
+ setDistrict(value.district || '');
32
+ } else if (value.lng !== undefined && value.lat !== undefined) {
33
+ setManual(true);
34
+ setLngTxt(String(value.lng));
35
+ setLatTxt(String(value.lat));
36
+ if (value.province) setProvince(value.province);
37
+ }
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, [value?.province, value?.city, value?.district, value?.lng, value?.lat]);
40
+
41
+ // 显示名归一化(档案里可能是无后缀名,如「广东」→「广东省」)
42
+ const provNames = allProvinces();
43
+ const provShow = provNames.includes(province) ? province : (provNames.find(p => p.replace(/壮族自治区|回族自治区|维吾尔自治区|特别行政区|自治区|省|市$/, '') === province.replace(/壮族自治区|回族自治区|维吾尔自治区|特别行政区|自治区|省|市$/, '')) || province);
44
+ const cities = citiesOf(provShow);
45
+ const districts = districtsOf(provShow, city);
46
+
47
+ const handleProvince = (v: string) => {
48
+ setProvince(v); setCity(''); setDistrict('');
49
+ onChange(null);
50
+ };
51
+ const handleCity = (v: string) => {
52
+ setCity(v); setDistrict('');
53
+ onChange(null);
54
+ };
55
+ const handleDistrict = (v: string) => {
56
+ setDistrict(v);
57
+ const sel = resolveLocation(province, city, v || '城区');
58
+ onChange(sel);
59
+ };
60
+
61
+ const manualLng = parseFloat(lngTxt);
62
+ const manualLat = parseFloat(latTxt);
63
+ const manualValid = manual && !isNaN(manualLng) && !isNaN(manualLat) && Math.abs(manualLng) <= 180 && Math.abs(manualLat) <= 90;
64
+ const manualSel: GeoSelection | null = manualValid ? { province, city, district: district || '手动定位', lng: manualLng, lat: manualLat } : null;
65
+ const sel = manualSel ?? resolveLocation(province, city, district || '城区');
66
+
67
+ return (
68
+ <div>
69
+ <div className="field-row">
70
+ <div className="field">
71
+ <label htmlFor="loc-province">省份</label>
72
+ <SongSearchSelect id="loc-province" value={provShow} placeholder="择省…" options={provNames.map(p => ({ value: p, label: p }))} onChange={handleProvince} />
73
+ </div>
74
+ <div className="field">
75
+ <label htmlFor="loc-city">城市</label>
76
+ <SongSearchSelect id="loc-city" value={city} placeholder="择市…" options={cities.map(c => ({ value: c.name, label: c.name }))} onChange={handleCity} disabled={!province} />
77
+ </div>
78
+ </div>
79
+ <div className="field">
80
+ <label htmlFor="loc-district">县/区(选至区县;乡镇村请用下方手动定位)</label>
81
+ <SongSearchSelect id="loc-district" value={district} placeholder="择区/县…" options={districts.map(d => ({ value: d.name, label: d.name }))} onChange={handleDistrict} disabled={!city} allowCustom />
82
+ </div>
83
+ <div className="field">
84
+ <label style={{ display: 'flex', alignItems: 'center', gap: '.5rem', cursor: 'pointer' }}>
85
+ <input type="checkbox" checked={manual} onChange={e => { setManual(e.target.checked); onChange(manualSel); }} style={{ width: 'auto' }} />
86
+ 手动定位(乡镇/村等未收录地点:从地图 App 获取经纬度填入)
87
+ </label>
88
+ {manual && (
89
+ <div className="field-row" style={{ marginTop: '.4rem' }}>
90
+ <div className="field"><label htmlFor="loc-lng">东经(如 121.796)</label>
91
+ <input className="input-line" id="loc-lng" type="number" step="0.0001" placeholder="121.796" value={lngTxt} onChange={e => { setLngTxt(e.target.value); onChange(manualValid ? manualSel : null); }} /></div>
92
+ <div className="field"><label htmlFor="loc-lat">北纬(如 41.599)</label>
93
+ <input className="input-line" id="loc-lat" type="number" step="0.0001" placeholder="41.599" value={latTxt} onChange={e => { setLatTxt(e.target.value); onChange(manualValid ? manualSel : null); }} /></div>
94
+ </div>
95
+ )}
96
+ </div>
97
+ {sel && !manual && district && !districts.some(d => d.name === district) && (
98
+ <p className="hint" style={{ color: 'var(--cinnabar)' }}>该区县未收录坐标,暂以城市中心计;如需乡镇/村级精度,请勾选手动定位。</p>
99
+ )}
100
+ {manualValid && <p className="hint" style={{ color: 'var(--celadon-deep)' }}>已用手动坐标(优先于行政区划)</p>}
101
+ {sel && (
102
+ <p className="hint">
103
+ 经纬 {sel.lng.toFixed(2)}°E · {sel.lat.toFixed(2)}°N
104
+ {previewHourIndex !== undefined && (() => {
105
+ // 真太阳时预览:按时辰中点估算
106
+ const hour = (previewHourIndex * 2) % 24;
107
+ const t = trueSolarTime(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate(), hour, 0, sel.lng, false);
108
+ const corrected = Math.floor(t.trueSolarHours);
109
+ const mins = Math.round((t.trueSolarHours - corrected) * 60);
110
+ const correctedIdx = Math.floor(((t.trueSolarHours + 1) % 24) / 2);
111
+ return ` · ${HOUR_LABELS[previewHourIndex]}校正为 ${String(corrected).padStart(2, '0')}:${String(mins).padStart(2, '0')}(${HOUR_LABELS[correctedIdx]})`;
112
+ })()}
113
+ </p>
114
+ )}
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,65 @@
1
+ // 墨尘粒子:玄学字符漂浮(借鉴「青囊」motes 动效,宋式淡墨风格)
2
+ // 固定种子生成位置,组件内稳定;负延迟错峰 + 各异时长实现自然漂移
3
+ import { useMemo } from 'react';
4
+
5
+ const GLYPHS = [
6
+ '甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸',
7
+ '子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥',
8
+ '观', '微', '知', '著', '星', '卦', '易', '遁', '命', '课', '掌', '镜', '穹', '历', '参', '悟',
9
+ ];
10
+
11
+ function mulberry32(seed: number) {
12
+ let a = seed >>> 0;
13
+ return () => {
14
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
15
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
16
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
17
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
18
+ };
19
+ }
20
+
21
+ interface MoteSpec {
22
+ glyph: string;
23
+ left: string;
24
+ top: string;
25
+ delay: string;
26
+ duration: string;
27
+ drift: string;
28
+ size: string;
29
+ }
30
+
31
+ export default function Motes({ count = 22, seed = 20260818 }: { count?: number; seed?: number }) {
32
+ const specs = useMemo<MoteSpec[]>(() => {
33
+ const rnd = mulberry32(seed);
34
+ return Array.from({ length: count }, () => ({
35
+ glyph: GLYPHS[Math.floor(rnd() * GLYPHS.length)],
36
+ left: (rnd() * 96).toFixed(1) + '%',
37
+ top: (rnd() * 88).toFixed(1) + '%',
38
+ delay: (-rnd() * 14).toFixed(2) + 's',
39
+ duration: (7 + rnd() * 6).toFixed(2) + 's',
40
+ drift: (-(rnd() * 26 - 13)).toFixed(0) + 'px',
41
+ size: (0.6 + rnd() * 0.9).toFixed(2) + 'rem',
42
+ }));
43
+ }, [count, seed]);
44
+
45
+ return (
46
+ <div className="motes" aria-hidden="true">
47
+ {specs.map((s, i) => (
48
+ <span
49
+ key={i}
50
+ className="mote"
51
+ style={{
52
+ left: s.left,
53
+ top: s.top,
54
+ animationDelay: s.delay,
55
+ animationDuration: s.duration,
56
+ ['--mote-drift' as string]: s.drift,
57
+ fontSize: s.size,
58
+ }}
59
+ >
60
+ {s.glyph}
61
+ </span>
62
+ ))}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,80 @@
1
+ import { Link, useLocation } from 'react-router-dom';
2
+ import { useState } from 'react';
3
+ import { Sparkles, History, Menu, X, Library } from 'lucide-react';
4
+
5
+ const navItems = [
6
+ { path: '/', label: '首页', icon: Sparkles },
7
+ { path: '/tarot', label: '塔罗占卜', icon: Sparkles },
8
+ { path: '/spread-library', label: '牌阵库', icon: Library },
9
+ { path: '/history', label: '历史记录', icon: History },
10
+ ];
11
+
12
+ export default function Navigation() {
13
+ const location = useLocation();
14
+ const [mobileOpen, setMobileOpen] = useState(false);
15
+
16
+ return (
17
+ <nav className="fixed top-0 left-0 right-0 z-50 bg-void/80 backdrop-blur-md border-b border-ancient/20">
18
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
19
+ <div className="flex items-center justify-between h-16">
20
+ <Link to="/" className="flex items-center gap-2 group">
21
+ <Sparkles className="w-6 h-6 text-ancient group-hover:animate-pulse-glow" />
22
+ <span className="font-display text-xl text-ancient tracking-wider">玄冥占星</span>
23
+ </Link>
24
+
25
+ {/* Desktop nav */}
26
+ <div className="hidden md:flex items-center gap-8">
27
+ {navItems.map((item) => {
28
+ const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/');
29
+ return (
30
+ <Link
31
+ key={item.path}
32
+ to={item.path}
33
+ className={`relative text-sm tracking-wide transition-colors duration-300 ${
34
+ isActive ? 'text-ancient' : 'text-stardust/70 hover:text-ancient'
35
+ }`}
36
+ >
37
+ {item.label}
38
+ {isActive && (
39
+ <span className="absolute -bottom-1 left-0 right-0 h-px bg-ancient animate-pulse-glow" />
40
+ )}
41
+ </Link>
42
+ );
43
+ })}
44
+ </div>
45
+
46
+ {/* Mobile menu button */}
47
+ <button
48
+ className="md:hidden text-stardust/70 hover:text-ancient"
49
+ onClick={() => setMobileOpen(!mobileOpen)}
50
+ >
51
+ {mobileOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
52
+ </button>
53
+ </div>
54
+ </div>
55
+
56
+ {/* Mobile nav */}
57
+ {mobileOpen && (
58
+ <div className="md:hidden bg-void/95 backdrop-blur-md border-t border-ancient/20">
59
+ <div className="px-4 py-4 space-y-3">
60
+ {navItems.map((item) => {
61
+ const isActive = location.pathname === item.path;
62
+ return (
63
+ <Link
64
+ key={item.path}
65
+ to={item.path}
66
+ onClick={() => setMobileOpen(false)}
67
+ className={`block py-2 text-sm tracking-wide ${
68
+ isActive ? 'text-ancient' : 'text-stardust/70'
69
+ }`}
70
+ >
71
+ {item.label}
72
+ </Link>
73
+ );
74
+ })}
75
+ </div>
76
+ </div>
77
+ )}
78
+ </nav>
79
+ );
80
+ }
@@ -0,0 +1,40 @@
1
+ // 档案编辑表单:出生日期(公历/农历)+ 精确时刻 + 性别 + 出生地点
2
+ import { useState } from 'react';
3
+ import DateInput from '@/components/DateInput';
4
+ import TimeShichenInput from '@/components/TimeShichenInput';
5
+ import LocationPicker from '@/components/LocationPicker';
6
+ import SongSelect from '@/components/SongSelect';
7
+ import type { GeoLocation } from '@core/types';
8
+ import type { UserProfile } from '@/utils/userStore';
9
+ import { timeToHourIndex } from '@/data/shichen';
10
+
11
+ interface Props {
12
+ initial: UserProfile;
13
+ onChange: (p: UserProfile) => void;
14
+ }
15
+
16
+ export default function ProfileForm({ initial, onChange }: Props) {
17
+ const [date, setDate] = useState(initial.birthDate);
18
+ const [time, setTime] = useState(initial.birthTime || '12:00');
19
+ const [gender, setGender] = useState<'男' | '女'>(initial.gender);
20
+ const [loc, setLoc] = useState<GeoLocation | null>(initial.location);
21
+ const emit = (patch: Partial<UserProfile>) => {
22
+ onChange({
23
+ birthDate: patch.birthDate ?? date,
24
+ birthTime: patch.birthTime ?? time,
25
+ birthHourIndex: timeToHourIndex(patch.birthTime ?? time),
26
+ gender: patch.gender ?? gender,
27
+ location: patch.location !== undefined ? patch.location : loc,
28
+ });
29
+ };
30
+ return (
31
+ <>
32
+ <div className="field"><label>出生日期(公历 / 农历)</label><DateInput value={date} onChange={v => { setDate(v); emit({ birthDate: v }); }} /></div>
33
+ <div className="field"><label>出生时刻(精确)</label><TimeShichenInput value={time} onChange={v => { setTime(v); emit({ birthTime: v }); }} /></div>
34
+ <div className="field-row">
35
+ <div className="field"><label>性别</label><SongSelect value={gender} options={[{ value: '男', label: '男' }, { value: '女', label: '女' }]} onChange={v => { setGender(v as '男' | '女'); emit({ gender: v as '男' | '女' }); }} /></div>
36
+ </div>
37
+ <div className="field"><label>出生地点</label><LocationPicker value={loc} onChange={v => { setLoc(v); emit({ location: v }); }} previewHourIndex={timeToHourIndex(time)} /></div>
38
+ </>
39
+ );
40
+ }
@@ -0,0 +1,40 @@
1
+ // 档案选择器:从已存档案(主档案 + 示例档案)填充面板输入
2
+ import { useState } from 'react';
3
+ import { currentUser, type UserProfile } from '@/utils/userStore';
4
+ import SongSelect from '@/components/SongSelect';
5
+
6
+ interface Props {
7
+ onPick: (p: UserProfile, source?: { id: string; name: string }) => void;
8
+ onPicked?: () => void;
9
+ }
10
+
11
+ export default function ProfilePicker({ onPick, onPicked }: Props) {
12
+ const user = currentUser();
13
+ const [value, setValue] = useState('');
14
+ if (!user) return null;
15
+ const items = [
16
+ { value: 'primary', label: '主档案 · ' + (user.profile.birthDate || '未录') },
17
+ ...user.samples.map(s => ({ value: s.id, label: s.name + ' · ' + (s.profile.birthDate || '未录') })),
18
+ ];
19
+ if (items.length <= 1) return null;
20
+ const pick = (v: string) => {
21
+ if (v === 'primary') onPick(user.profile, { id: 'main', name: '主档案' });
22
+ else {
23
+ const s = user.samples.find(x => x.id === v);
24
+ if (s) onPick(s.profile, { id: s.id, name: s.name });
25
+ }
26
+ setValue('');
27
+ onPicked?.();
28
+ };
29
+ return (
30
+ <div className="field" style={{ marginTop: 'var(--sp-2)' }}>
31
+ <label>取自档案</label>
32
+ <SongSelect
33
+ value={value}
34
+ placeholder="择档案以填…"
35
+ options={items}
36
+ onChange={pick}
37
+ />
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,47 @@
1
+ // 所问之事的统一输入:类型选择 + 问题输入(占问类必填区,命盘类可留白)
2
+ import { useState } from 'react';
3
+ import SongSelect from '@/components/SongSelect';
4
+
5
+ const DEFAULT_TYPES = [
6
+ { value: 'love', label: '情感姻缘' },
7
+ { value: 'career', label: '事业学业' },
8
+ { value: 'wealth', label: '财运投资' },
9
+ { value: 'health', label: '健康' },
10
+ { value: 'travel', label: '出行方位' },
11
+ { value: 'lost', label: '寻物' },
12
+ { value: 'decision', label: '抉择决策' },
13
+ { value: 'other', label: '其他' },
14
+ ];
15
+
16
+ interface Props {
17
+ question: string;
18
+ onQuestion: (q: string) => void;
19
+ qType?: string;
20
+ onQType?: (t: string) => void;
21
+ showType?: boolean; // 占问类:先选类型
22
+ placeholder?: string;
23
+ }
24
+
25
+ export default function QuestionFields({ question, onQuestion, qType, onQType, showType = true, placeholder }: Props) {
26
+ return (
27
+ <div>
28
+ {showType && onQType && qType !== undefined && (
29
+ <div className="field">
30
+ <label htmlFor="q-type">所问之属</label>
31
+ <SongSelect id="q-type" value={qType} options={DEFAULT_TYPES} onChange={onQType} />
32
+ </div>
33
+ )}
34
+ <div className="field">
35
+ <label htmlFor="q-input">所问之事</label>
36
+ <input
37
+ className="input-line"
38
+ id="q-input"
39
+ placeholder={placeholder || '默念所问,书之于此,心念自随…'}
40
+ maxLength={60}
41
+ value={question}
42
+ onChange={e => onQuestion(e.target.value)}
43
+ />
44
+ </div>
45
+ </div>
46
+ );
47
+ }