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.
- package/LICENSE +21 -0
- package/README.en.md +202 -0
- package/README.md +247 -0
- package/dist/assets/index-CsOD5rSo.js +102 -0
- package/dist/assets/index-CsOD5rSo.js.map +1 -0
- package/dist/assets/index-DQx62TUr.css +1 -0
- package/dist/favicon.svg +12 -0
- package/dist/index.html +15 -0
- package/eslint.config.js +28 -0
- package/index.html +25 -0
- package/package.json +96 -0
- package/postcss.config.js +10 -0
- package/public/favicon.svg +12 -0
- package/scripts/guanwei +245 -0
- package/scripts/install.sh +79 -0
- package/scripts/release.sh +42 -0
- package/scripts/setup.bat +36 -0
- package/scripts/setup.sh +123 -0
- package/server/src/data/db.json +12059 -0
- package/server/src/data/guanwei.db +0 -0
- package/server/src/data/lifeContext.ts +423 -0
- package/server/src/data/spreads.ts +255 -0
- package/server/src/data/tarotCards.ts +2353 -0
- package/server/src/index.ts +71 -0
- package/server/src/routes/ai.ts +376 -0
- package/server/src/routes/divine.ts +140 -0
- package/server/src/routes/hour.ts +40 -0
- package/server/src/routes/tarot.ts +129 -0
- package/server/src/routes/users.ts +140 -0
- package/server/src/services/chartBrief.ts +131 -0
- package/server/src/services/divineStore.ts +188 -0
- package/server/src/services/hourInference.ts +150 -0
- package/server/src/services/llmProvider.ts +181 -0
- package/server/src/services/promptBuilder.ts +327 -0
- package/server/src/services/relativesCheck.ts +139 -0
- package/server/src/services/sixRelatives.ts +70 -0
- package/server/src/services/skills.ts +116 -0
- package/server/src/types/index.ts +104 -0
- package/server/src/utils/semanticAnalyzer.ts +555 -0
- package/server/src/utils/tarotEngine.ts +849 -0
- package/server/tsconfig.json +18 -0
- package/shared/core/data/classics.ts +276 -0
- package/shared/core/data/courses.ts +211 -0
- package/shared/core/data/duanyu.ts +148 -0
- package/shared/core/data/ganzhi.ts +53 -0
- package/shared/core/data/gua64.ts +93 -0
- package/shared/core/data/liuren.ts +18 -0
- package/shared/core/data/liuyao.ts +73 -0
- package/shared/core/data/qimen.ts +27 -0
- package/shared/core/data/region-full.ts +3 -0
- package/shared/core/data/region.ts +310 -0
- package/shared/core/data/tarotSpreads.ts +78 -0
- package/shared/core/data/xiaoliuren.ts +18 -0
- package/shared/core/data/ziwei.ts +97 -0
- package/shared/core/data/zodiac.ts +20 -0
- package/shared/core/engine/astrology.ts +169 -0
- package/shared/core/engine/bazi.ts +306 -0
- package/shared/core/engine/calendar.ts +127 -0
- package/shared/core/engine/daily.ts +133 -0
- package/shared/core/engine/liuren.ts +86 -0
- package/shared/core/engine/liuyao.ts +93 -0
- package/shared/core/engine/meihua.ts +87 -0
- package/shared/core/engine/qimen.ts +78 -0
- package/shared/core/engine/questionFit.ts +94 -0
- package/shared/core/engine/tarot.ts +69 -0
- package/shared/core/engine/trueSolarTime.ts +86 -0
- package/shared/core/engine/xiaoliuren.ts +19 -0
- package/shared/core/engine/ziwei.ts +217 -0
- package/shared/core/engine/ziweiInterpret.ts +161 -0
- package/shared/core/types.ts +206 -0
- package/src/App.tsx +69 -0
- package/src/arts/registry.ts +31 -0
- package/src/assets/react.svg +1 -0
- package/src/components/Backdrop.tsx +27 -0
- package/src/components/BambooArt.tsx +41 -0
- package/src/components/CalendarInput.tsx +131 -0
- package/src/components/DateInput.tsx +103 -0
- package/src/components/Disclaimer.tsx +12 -0
- package/src/components/Empty.tsx +8 -0
- package/src/components/ErrorBoundary.tsx +38 -0
- package/src/components/HomeSideAnchor.tsx +62 -0
- package/src/components/InkVein.tsx +10 -0
- package/src/components/LifeEventsInput.tsx +47 -0
- package/src/components/LocationPicker.tsx +117 -0
- package/src/components/Motes.tsx +65 -0
- package/src/components/Navigation.tsx +80 -0
- package/src/components/ProfileForm.tsx +40 -0
- package/src/components/ProfilePicker.tsx +40 -0
- package/src/components/QuestionFields.tsx +47 -0
- package/src/components/ReportView.tsx +211 -0
- package/src/components/ResultCard.tsx +40 -0
- package/src/components/SealButton.tsx +18 -0
- package/src/components/SiteFooter.tsx +17 -0
- package/src/components/SiteNav.tsx +103 -0
- package/src/components/SongDialog.tsx +64 -0
- package/src/components/SongSearchSelect.tsx +81 -0
- package/src/components/SongSelect.tsx +61 -0
- package/src/components/StarField.tsx +119 -0
- package/src/components/TarotCard.tsx +129 -0
- package/src/components/TimeShichenInput.tsx +29 -0
- package/src/components/arts/AstrologyArt.tsx +141 -0
- package/src/components/arts/BaziArt.tsx +269 -0
- package/src/components/arts/LiurenArt.tsx +87 -0
- package/src/components/arts/LiuyaoArt.tsx +111 -0
- package/src/components/arts/MeihuaArt.tsx +105 -0
- package/src/components/arts/QimenArt.tsx +86 -0
- package/src/components/arts/TarotArt.tsx +110 -0
- package/src/components/arts/XiaoliurenArt.tsx +93 -0
- package/src/components/arts/ZiweiArt.tsx +296 -0
- package/src/data/arts.ts +84 -0
- package/src/data/lifeContext.ts +423 -0
- package/src/data/sample-report.json +146 -0
- package/src/data/shichen.ts +21 -0
- package/src/data/spreads.ts +255 -0
- package/src/data/tarotCards.ts +2353 -0
- package/src/hooks/useAIInterpret.ts +76 -0
- package/src/hooks/useDivine.ts +39 -0
- package/src/hooks/useTheme.ts +29 -0
- package/src/index.css +190 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +12 -0
- package/src/pages/AboutPage.tsx +25 -0
- package/src/pages/AcademyPage.tsx +128 -0
- package/src/pages/AstrologyPage.tsx +33 -0
- package/src/pages/AuthPage.tsx +263 -0
- package/src/pages/BaZiPage.tsx +33 -0
- package/src/pages/ClassicsPage.tsx +120 -0
- package/src/pages/DemoPage.tsx +161 -0
- package/src/pages/HistoryPage.tsx +133 -0
- package/src/pages/Home.tsx +179 -0
- package/src/pages/HomePage.tsx +174 -0
- package/src/pages/ModulePage.tsx +320 -0
- package/src/pages/SpreadEditor.tsx +230 -0
- package/src/pages/SpreadEditorPage.tsx +105 -0
- package/src/pages/SpreadLibrary.tsx +199 -0
- package/src/pages/SpreadLibraryPage.tsx +17 -0
- package/src/pages/TarotPage.tsx +738 -0
- package/src/pages/ZiWeiPage.tsx +33 -0
- package/src/services/api.ts +342 -0
- package/src/stores/useDivinationStore.ts +22 -0
- package/src/styles/song.css +1066 -0
- package/src/styles/tokens.css +60 -0
- package/src/types/index.ts +104 -0
- package/src/utils/astrologyCalc.ts +61 -0
- package/src/utils/comboEngine.ts +134 -0
- package/src/utils/dst.ts +24 -0
- package/src/utils/panTone.ts +206 -0
- package/src/utils/recordStore.ts +60 -0
- package/src/utils/semanticAnalyzer.ts +555 -0
- package/src/utils/spreadStorage.ts +32 -0
- package/src/utils/storage.ts +27 -0
- package/src/utils/tarotEngine.ts +849 -0
- package/src/utils/userStore.ts +234 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +52 -0
- package/tsconfig.json +40 -0
- package/vite.config.ts +34 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// AI 解读报告结构化展示:按 Schema 匹配填充(命盘类/占问类)
|
|
2
|
+
import type { AIReport } from '@/services/api';
|
|
3
|
+
import { ResultCard } from '@/components/ResultCard';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
report: AIReport;
|
|
7
|
+
artName: string;
|
|
8
|
+
onExport: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function ReportView({ report, artName, onExport }: Props) {
|
|
12
|
+
const r = report;
|
|
13
|
+
return (
|
|
14
|
+
<div className="report-view">
|
|
15
|
+
{/* 标题与总述 */}
|
|
16
|
+
<div className="result-card">
|
|
17
|
+
<h3>{r.title || '观微解读报告'}</h3>
|
|
18
|
+
<p className="tiny muted" style={{ marginBottom: '.5rem' }}>术别:{artName}</p>
|
|
19
|
+
{r.overview && <p className="result-text" style={{ fontSize: '1rem', color: 'var(--ink)' }}>{r.overview}</p>}
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
{/* 适问之辨 */}
|
|
23
|
+
{r.suitability && (r.suitability.note || r.suitability.suggestion) && (
|
|
24
|
+
<div className={"result-card fit-card " + (r.suitability.suitable === true ? 'fit-ok' : r.suitability.suitable === 'partial' ? 'fit-partial' : 'fit-bad')}>
|
|
25
|
+
<h3>适问之辨</h3>
|
|
26
|
+
{r.suitability.note && <p className="result-text">{r.suitability.note}</p>}
|
|
27
|
+
{r.suitability.suggestion && <p className="result-text"><strong>✦ 建议:</strong>{r.suitability.suggestion}</p>}
|
|
28
|
+
</div>
|
|
29
|
+
)}
|
|
30
|
+
|
|
31
|
+
{/* 原始解读 */}
|
|
32
|
+
{(r.rawReading?.summary || r.rawReading?.keyPoints?.length) && (
|
|
33
|
+
<div className="result-card">
|
|
34
|
+
<h3>原始解读 · 先明盘面</h3>
|
|
35
|
+
{r.rawReading.summary && <p className="result-text">{r.rawReading.summary}</p>}
|
|
36
|
+
{r.rawReading.keyPoints.length > 0 && (
|
|
37
|
+
<ul className="report-list mt-2">
|
|
38
|
+
{r.rawReading.keyPoints.map((k, i) => <li key={i}>{k}</li>)}
|
|
39
|
+
</ul>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
|
|
44
|
+
{/* 命盘类:性格/人生阶段/事业/爱情/财富 */}
|
|
45
|
+
{r.kind === 'mingpan' && (
|
|
46
|
+
<>
|
|
47
|
+
{r.character && (r.character.summary || r.character.traits.length > 0) && (
|
|
48
|
+
<div className="result-card">
|
|
49
|
+
<h3>命主性格</h3>
|
|
50
|
+
{r.character.summary && <p className="result-text">{r.character.summary}</p>}
|
|
51
|
+
{r.character.traits.length > 0 && (
|
|
52
|
+
<div className="trait-grid mt-3">
|
|
53
|
+
{r.character.traits.map((t, i) => (
|
|
54
|
+
<div className="trait-cell" key={i}>
|
|
55
|
+
<div className="trait-name">{t.name}</div>
|
|
56
|
+
<div className="trait-desc">{t.desc}</div>
|
|
57
|
+
</div>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
{r.lifeStages && r.lifeStages.length > 0 && (
|
|
65
|
+
<div className="result-card">
|
|
66
|
+
<h3>人生阶段</h3>
|
|
67
|
+
<div className="timeline mt-3">
|
|
68
|
+
{r.lifeStages.map((s, i) => (
|
|
69
|
+
<div className="tl-item" key={i}>
|
|
70
|
+
<div className="tl-marker"><span className="tl-dot" /></div>
|
|
71
|
+
<div className="tl-body">
|
|
72
|
+
<div className="tl-head"><strong>{s.stage}</strong><span className="tl-age">{s.age}</span></div>
|
|
73
|
+
<p className="tl-summary">{s.summary}</p>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
{(r.family?.background || r.family?.parents || r.family?.imprint) && (
|
|
82
|
+
<div className="result-card">
|
|
83
|
+
<h3>原生家庭</h3>
|
|
84
|
+
{r.family.background && (
|
|
85
|
+
<div className="fam-block"><p className="tiny muted" style={{ margin: '.3rem 0 .4rem' }}>家境与氛围</p><p className="result-text">{r.family.background}</p></div>
|
|
86
|
+
)}
|
|
87
|
+
{r.family.parents && (
|
|
88
|
+
<div className="fam-block"><p className="tiny muted" style={{ margin: '.3rem 0 .4rem' }}>父母关系</p><p className="result-text">{r.family.parents}</p></div>
|
|
89
|
+
)}
|
|
90
|
+
{r.family.imprint && (
|
|
91
|
+
<div className="fam-block"><p className="tiny muted" style={{ margin: '.3rem 0 .4rem' }}>家庭印记</p><p className="result-text">{r.family.imprint}</p></div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{(r.mind?.action || r.mind?.pattern || r.mind?.growth) && (
|
|
97
|
+
<div className="result-card">
|
|
98
|
+
<h3>心智与行动模式</h3>
|
|
99
|
+
{r.mind.action && (
|
|
100
|
+
<div className="mind-block"><p className="tiny muted" style={{ margin: '.3rem 0 .4rem' }}>行动力与坚持</p><p className="result-text">{r.mind.action}</p></div>
|
|
101
|
+
)}
|
|
102
|
+
{r.mind.pattern && (
|
|
103
|
+
<div className="mind-block"><p className="tiny muted" style={{ margin: '.3rem 0 .4rem' }}>行为循环</p><p className="result-text">{r.mind.pattern}</p></div>
|
|
104
|
+
)}
|
|
105
|
+
{r.mind.growth && (
|
|
106
|
+
<div className="mind-block"><p className="tiny muted" style={{ margin: '.3rem 0 .4rem' }}>成长方向</p><p className="result-text">{r.mind.growth}</p></div>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{(r.career?.summary || r.love?.summary || r.wealth?.summary || r.health?.summary) && (
|
|
112
|
+
<div className="result-card">
|
|
113
|
+
<h3>学业事业 · 爱情 · 财富 · 健康</h3>
|
|
114
|
+
<div className="domain-grid mt-3">
|
|
115
|
+
{r.career?.summary && (
|
|
116
|
+
<div className="domain-cell">
|
|
117
|
+
<div className="domain-name">学业 · 事业</div>
|
|
118
|
+
<p>{r.career.summary}</p>
|
|
119
|
+
{r.career.direction && <p className="tiny muted">宜向:{r.career.direction}</p>}
|
|
120
|
+
{r.career.advice && <p className="tiny">建议:{r.career.advice}</p>}
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
{r.love?.summary && (
|
|
124
|
+
<div className="domain-cell">
|
|
125
|
+
<div className="domain-name">爱情</div>
|
|
126
|
+
<p>{r.love.summary}</p>
|
|
127
|
+
{r.love.advice && <p className="tiny">建议:{r.love.advice}</p>}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
{r.wealth?.summary && (
|
|
131
|
+
<div className="domain-cell">
|
|
132
|
+
<div className="domain-name">财富</div>
|
|
133
|
+
<p>{r.wealth.summary}</p>
|
|
134
|
+
{r.wealth.advice && <p className="tiny">建议:{r.wealth.advice}</p>}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
{r.health?.summary && (
|
|
138
|
+
<div className="domain-cell">
|
|
139
|
+
<div className="domain-name">健康</div>
|
|
140
|
+
<p>{r.health.summary}</p>
|
|
141
|
+
{r.health.advice && <p className="tiny">建议:{r.health.advice}</p>}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* 占问类:现状/趋势/时机 */}
|
|
151
|
+
{r.kind === 'zhanwen' && (
|
|
152
|
+
<div className="result-card">
|
|
153
|
+
<h3>局势 · 趋势 · 时机</h3>
|
|
154
|
+
<div className="domain-grid mt-3">
|
|
155
|
+
{r.situation && <div className="domain-cell"><div className="domain-name">当下局势</div><p>{r.situation}</p></div>}
|
|
156
|
+
{r.trend && <div className="domain-cell"><div className="domain-name">发展趋势</div><p>{r.trend}</p></div>}
|
|
157
|
+
{r.timing && <div className="domain-cell"><div className="domain-name">时机宜忌</div><p>{r.timing}</p></div>}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{/* 建议 */}
|
|
163
|
+
{r.advice && (
|
|
164
|
+
<div className="result-card">
|
|
165
|
+
<h3>参详建议</h3>
|
|
166
|
+
<ul className="report-list mt-1">
|
|
167
|
+
{r.advice.split(/[;;]/).filter(Boolean).map((a, i) => <li key={i}>{a.trim()}</li>)}
|
|
168
|
+
</ul>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{/* 结语 */}
|
|
173
|
+
{r.conclusion && (
|
|
174
|
+
<div className="result-card">
|
|
175
|
+
<h3>结语</h3>
|
|
176
|
+
<p className="result-text">{r.conclusion}</p>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{/* AI 自定义扩展章节兜底(family/mind 之外的任意字段 / 旧版 chapters 结构):任何内容都不丢失 */}
|
|
181
|
+
{((r as any).extraSections?.length > 0 || (r as any).chapters?.length > 0) && (
|
|
182
|
+
<div className="result-card">
|
|
183
|
+
<h3>更多参详</h3>
|
|
184
|
+
{(r as any).chapters?.map((c: any, i: number) => (
|
|
185
|
+
<div key={'ch' + i} style={{ marginBottom: 'var(--sp-2)' }}>
|
|
186
|
+
<p className="tag-cool" style={{ marginBottom: '.3rem' }}><strong>{c.skill || '参详'}</strong></p>
|
|
187
|
+
<p className="result-text" style={{ whiteSpace: 'pre-wrap' }}>{c.content}</p>
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
{(r as any).extraSections?.map((s: any, i: number) => (
|
|
191
|
+
<div key={'ex' + i} style={{ marginBottom: 'var(--sp-2)' }}>
|
|
192
|
+
<p className="tag-cool" style={{ marginBottom: '.3rem' }}><strong>{s.skill || '参详'}</strong></p>
|
|
193
|
+
<p className="result-text" style={{ whiteSpace: 'pre-wrap' }}>{s.content}</p>
|
|
194
|
+
</div>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{/* 免责 + 导出 */}
|
|
200
|
+
<p className="tiny muted" style={{ marginTop: 'var(--sp-2)' }}>{r.disclaimer}</p>
|
|
201
|
+
<div className="btn-row mt-3">
|
|
202
|
+
<button className="btn-seal btn-ghost" style={{ fontSize: '.82rem', padding: '.4rem 1.1rem' }} onClick={onExport}>
|
|
203
|
+
导 出 报 告
|
|
204
|
+
</button>
|
|
205
|
+
<button className="btn-seal btn-ghost" style={{ fontSize: '.82rem', padding: '.4rem 1.1rem' }} onClick={() => window.print()}>
|
|
206
|
+
存 为 PDF
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// 结果卡(各术结果渲染的基础容器,现代解读层正文)
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface Cell { k: string; v: ReactNode; big?: boolean; hot?: boolean; cool?: boolean; }
|
|
5
|
+
|
|
6
|
+
interface ResultCardProps {
|
|
7
|
+
title: string;
|
|
8
|
+
cells?: Cell[];
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ResultCard({ title, cells, children, className = '' }: ResultCardProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className={`result-card ${className}`}>
|
|
16
|
+
<h3>{title}</h3>
|
|
17
|
+
{cells && cells.length > 0 && (
|
|
18
|
+
<div className="result-grid">
|
|
19
|
+
{cells.map((c, i) => (
|
|
20
|
+
<div className="result-cell" key={i}>
|
|
21
|
+
<div className="k">{c.k}</div>
|
|
22
|
+
<div className={`v ${c.big ? 'big' : ''} ${c.hot ? 'hot' : ''} ${c.cool ? 'cool' : ''}`}>{c.v}</div>
|
|
23
|
+
</div>
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
)}
|
|
27
|
+
{children && <div className="result-text">{children}</div>}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 结果占位(古风提示层)
|
|
33
|
+
export function ResultPlaceholder({ glyph = '卦', text = '静候卦象 · 观微知著' }: { glyph?: string; text?: string }) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="result-placeholder">
|
|
36
|
+
<div className="glyph" aria-hidden="true">{glyph}</div>
|
|
37
|
+
<p>{text}</p>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// 印章式按钮(古风提示层文案约定:按钮字为文言短句)
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
to?: string;
|
|
7
|
+
onClick?: () => void;
|
|
8
|
+
ghost?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function SealButton({ children, to, onClick, ghost, className = '' }: Props) {
|
|
13
|
+
const cls = `btn-seal ${ghost ? 'btn-ghost' : ''} ${className}`;
|
|
14
|
+
if (to) {
|
|
15
|
+
return <Link to={to} className={cls} onClick={onClick}><span className="sb-label">{children}</span></Link>;
|
|
16
|
+
}
|
|
17
|
+
return <button className={cls} onClick={onClick}><span className="sb-label">{children}</span></button>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// 页脚:品牌 + 免责声明(古风提示层)
|
|
2
|
+
export default function SiteFooter() {
|
|
3
|
+
return (
|
|
4
|
+
<footer className="site-foot">
|
|
5
|
+
<div className="wrap foot-inner">
|
|
6
|
+
<div className="foot-brand">
|
|
7
|
+
<span className="seal">观微</span>
|
|
8
|
+
<span>观微 · 玄学问道</span>
|
|
9
|
+
</div>
|
|
10
|
+
<div className="foot-meta">
|
|
11
|
+
<p>凡占问所得,仅供修身养性、怡情遣兴之用,不作决策依据。</p>
|
|
12
|
+
<p>术数乃先贤观天察地之遗智,敬而用之,勿迷勿执。</p>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
</footer>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// 顶部导航:首页 / 九术(分类下拉) / 法度 / 古籍 / 学馆 / 缘起 / 档案区(最右)
|
|
2
|
+
// 历史入口移入登录后档案菜单
|
|
3
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
4
|
+
import { currentUser, logout } from '@/utils/userStore';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { ARTS } from '@/data/arts';
|
|
7
|
+
|
|
8
|
+
// 九术分类(参考「青囊」术数推演下拉的分组方式)
|
|
9
|
+
const ART_GROUPS: { name: string; arts: string[] }[] = [
|
|
10
|
+
{ name: '命理', arts: ['bazi', 'ziwei'] },
|
|
11
|
+
{ name: '三式', arts: ['qimen', 'liuren'] },
|
|
12
|
+
{ name: '象数 · 筮法', arts: ['meihua', 'liuyao', 'xiaoliuren'] },
|
|
13
|
+
{ name: '西学镜鉴', arts: ['astrology', 'tarot'] },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export default function SiteNav() {
|
|
17
|
+
const location = useLocation();
|
|
18
|
+
const pathname = location.pathname;
|
|
19
|
+
const isArt = pathname.startsWith('/art/');
|
|
20
|
+
|
|
21
|
+
const isActive = (key: string): boolean => {
|
|
22
|
+
switch (key) {
|
|
23
|
+
case 'home': return pathname === '/' && !isArt;
|
|
24
|
+
case 'arts': return isArt;
|
|
25
|
+
case 'fa': return false;
|
|
26
|
+
case 'classics': return pathname.startsWith('/classics');
|
|
27
|
+
case 'academy': return pathname.startsWith('/academy');
|
|
28
|
+
case 'about': return pathname.startsWith('/about');
|
|
29
|
+
default: return false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<header className="site-head">
|
|
35
|
+
<div className="wrap head-inner">
|
|
36
|
+
<Link className="brand" to="/">
|
|
37
|
+
<span className="seal" aria-hidden="true">观微</span>
|
|
38
|
+
<span className="name">观微</span>
|
|
39
|
+
<span className="sub">以术问道 · 观微知著</span>
|
|
40
|
+
</Link>
|
|
41
|
+
<nav className="nav-links" aria-label="主导航">
|
|
42
|
+
<Link to="/" className={pathname === '/' && !isArt ? 'active' : ''}>首页</Link>
|
|
43
|
+
<ArtsDropdown active={isArt} />
|
|
44
|
+
<Link to="/classics" className={isActive('classics') ? 'active' : ''}>古籍</Link>
|
|
45
|
+
<Link to="/academy" className={isActive('academy') ? 'active' : ''}>学馆</Link>
|
|
46
|
+
<Link to="/about" className={isActive('about') ? 'active' : ''}>缘起</Link>
|
|
47
|
+
</nav>
|
|
48
|
+
<UserArea />
|
|
49
|
+
</div>
|
|
50
|
+
</header>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 九术分类下拉(hover 展开)
|
|
55
|
+
function ArtsDropdown({ active }: { active: boolean }) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="has-sub">
|
|
58
|
+
<Link to="/" className={active ? 'active' : ''} onClick={e => { e.preventDefault(); document.getElementById('home-arts')?.scrollIntoView({ behavior: 'smooth' }); }}>九术 <svg className="chev-mini" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg></Link>
|
|
59
|
+
<div className="sub-menu arts-sub" style={{ minWidth: '13rem' }}>
|
|
60
|
+
{ART_GROUPS.map(g => (
|
|
61
|
+
<div key={g.name} className="sub-group">
|
|
62
|
+
<div className="sub-group-name">{g.name}</div>
|
|
63
|
+
{g.arts.map(a => {
|
|
64
|
+
const art = ARTS.find(x => x.id === a)!;
|
|
65
|
+
return <Link key={a} to={'/art/' + a}><span className="sub-glyph">{art.glyph}</span>{art.name}<span className="sub-poem">{art.poem.split(',')[0]}</span></Link>;
|
|
66
|
+
})}
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 档案区(最右):未登录 → 入馆;登录后 → 名号下拉(档案/占问记录/离馆)
|
|
75
|
+
function UserArea() {
|
|
76
|
+
const user = currentUser();
|
|
77
|
+
const [, force] = useState(0);
|
|
78
|
+
if (!user) {
|
|
79
|
+
return <Link to="/auth" className="btn-seal btn-ghost" style={{ fontSize: '.78rem', padding: '.3rem 1rem', letterSpacing: '.2em' }}><span className="sb-label">入 馆</span></Link>;
|
|
80
|
+
}
|
|
81
|
+
// 用户名限 4 字,超出省略
|
|
82
|
+
const shortName = user.username.length > 4 ? user.username.slice(0, 4) + '…' : user.username;
|
|
83
|
+
return (
|
|
84
|
+
<div className="has-sub user-menu">
|
|
85
|
+
<button className="user-chip" onClick={() => {}}>
|
|
86
|
+
<span className="user-seal">{user.username.slice(0, 1)}</span>
|
|
87
|
+
<span className="user-name">{shortName}</span>
|
|
88
|
+
<span className="user-caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg></span>
|
|
89
|
+
</button>
|
|
90
|
+
<div className="sub-menu" style={{ right: 0, left: 'auto', transform: 'none' }}>
|
|
91
|
+
<Link to="/auth">吾之档案</Link>
|
|
92
|
+
<Link to="/history">占问记录</Link>
|
|
93
|
+
<Link
|
|
94
|
+
to="/"
|
|
95
|
+
onClick={() => { logout(); force(x => x + 1); }}
|
|
96
|
+
style={{ color: 'var(--cinnabar)' }}
|
|
97
|
+
>
|
|
98
|
+
离 馆
|
|
99
|
+
</Link>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SongDialog:宋式确认弹窗(替代系统 confirm/alert)
|
|
2
|
+
import { createContext, useCallback, useContext, useState } from 'react';
|
|
3
|
+
import type { ReactNode, CSSProperties } from 'react';
|
|
4
|
+
|
|
5
|
+
interface DialogState {
|
|
6
|
+
open: boolean;
|
|
7
|
+
title: string;
|
|
8
|
+
message: string;
|
|
9
|
+
confirmText: string;
|
|
10
|
+
danger: boolean;
|
|
11
|
+
resolve: ((ok: boolean) => void) | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DialogContext = createContext<{
|
|
15
|
+
confirm: (message: string, opts?: { title?: string; confirmText?: string; danger?: boolean }) => Promise<boolean>;
|
|
16
|
+
}>({ confirm: async () => false });
|
|
17
|
+
|
|
18
|
+
export function useConfirm() {
|
|
19
|
+
return useContext(DialogContext).confirm;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SongDialogProvider({ children }: { children: ReactNode }) {
|
|
23
|
+
const [state, setState] = useState<DialogState>({ open: false, title: '', message: '', confirmText: '确 定', danger: false, resolve: null });
|
|
24
|
+
|
|
25
|
+
const confirm = useCallback((message: string, opts?: { title?: string; confirmText?: string; danger?: boolean }) => {
|
|
26
|
+
return new Promise<boolean>(resolve => {
|
|
27
|
+
setState({
|
|
28
|
+
open: true,
|
|
29
|
+
title: opts?.title || '慎问',
|
|
30
|
+
message,
|
|
31
|
+
confirmText: opts?.confirmText || '确 定',
|
|
32
|
+
danger: !!opts?.danger,
|
|
33
|
+
resolve,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const close = (ok: boolean) => {
|
|
39
|
+
state.resolve?.(ok);
|
|
40
|
+
setState(s => ({ ...s, open: false, resolve: null }));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const dangerStyle: CSSProperties = state.danger ? { borderColor: 'var(--cinnabar)', color: 'var(--cinnabar)' } : {};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<DialogContext.Provider value={{ confirm }}>
|
|
47
|
+
{children}
|
|
48
|
+
{state.open && (
|
|
49
|
+
<div className="song-dialog-mask" onClick={() => close(false)}>
|
|
50
|
+
<div className="song-dialog" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true">
|
|
51
|
+
<div className="dlg-title">{state.title}</div>
|
|
52
|
+
<div className="dlg-body">{state.message}</div>
|
|
53
|
+
<div className="dlg-actions">
|
|
54
|
+
<button className="btn-seal btn-ghost" style={{ fontSize: '.85rem', padding: '.42rem 1.2rem' }} onClick={() => close(false)}>再 思</button>
|
|
55
|
+
<button className="btn-seal" style={{ fontSize: '.85rem', padding: '.42rem 1.2rem', ...dangerStyle }} onClick={() => close(true)}>
|
|
56
|
+
{state.confirmText}
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
</DialogContext.Provider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SongSearchSelect:可搜索的自定义下拉(自有 UI,替代系统 select)
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
value: string;
|
|
6
|
+
options: { value: string; label: string }[];
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
id?: string;
|
|
11
|
+
allowCustom?: boolean; // 允许输入未收录之项(以输入值为准)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function SongSearchSelect({ value, options, onChange, placeholder = '择…', disabled, id, allowCustom }: Props) {
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const [query, setQuery] = useState('');
|
|
17
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
18
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!open) return;
|
|
22
|
+
const close = (e: MouseEvent) => {
|
|
23
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
24
|
+
};
|
|
25
|
+
document.addEventListener('mousedown', close);
|
|
26
|
+
return () => document.removeEventListener('mousedown', close);
|
|
27
|
+
}, [open]);
|
|
28
|
+
|
|
29
|
+
// 打开时聚焦搜索框
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (open) setTimeout(() => inputRef.current?.focus(), 30);
|
|
32
|
+
}, [open]);
|
|
33
|
+
|
|
34
|
+
const filtered = useMemo(() => {
|
|
35
|
+
const q = query.trim();
|
|
36
|
+
if (!q) return options;
|
|
37
|
+
return options.filter(o => o.label.includes(q));
|
|
38
|
+
}, [options, query]);
|
|
39
|
+
|
|
40
|
+
const sel = options.find(o => o.value === value);
|
|
41
|
+
return (
|
|
42
|
+
<div className={'song-select song-search' + (open ? ' open' : '')} ref={ref}>
|
|
43
|
+
<button type="button" className="trigger" id={id} disabled={disabled} onClick={() => setOpen(v => !v)} aria-haspopup="listbox" aria-expanded={open}>
|
|
44
|
+
<span>{sel ? sel.label : placeholder}</span>
|
|
45
|
+
<svg className="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
46
|
+
<path d="m6 9 6 6 6-6" />
|
|
47
|
+
</svg>
|
|
48
|
+
</button>
|
|
49
|
+
{open && (
|
|
50
|
+
<div className="panel" role="listbox">
|
|
51
|
+
<div className="ss-search">
|
|
52
|
+
<input
|
|
53
|
+
ref={inputRef}
|
|
54
|
+
className="ss-input"
|
|
55
|
+
placeholder="输入以搜…"
|
|
56
|
+
value={query}
|
|
57
|
+
onChange={e => setQuery(e.target.value)}
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
<div className="ss-list">
|
|
61
|
+
{filtered.map(o => (
|
|
62
|
+
<button
|
|
63
|
+
key={o.value}
|
|
64
|
+
type="button"
|
|
65
|
+
className={o.value === value ? 'sel' : ''}
|
|
66
|
+
onClick={() => { onChange(o.value); setOpen(false); setQuery(''); }}
|
|
67
|
+
>
|
|
68
|
+
{o.label}
|
|
69
|
+
</button>
|
|
70
|
+
))}
|
|
71
|
+
{filtered.length === 0 && (allowCustom && query.trim() ? (
|
|
72
|
+
<button type="button" className="ss-custom" onClick={() => { onChange(query.trim()); setOpen(false); setQuery(''); }}>
|
|
73
|
+
取「{query.trim()}」为区县(以城坐标计)
|
|
74
|
+
</button>
|
|
75
|
+
) : <div className="ss-empty">无契合之项</div>)}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SongSelect:宋式自定义下拉(替代系统 select)
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
export interface SongOption { value: string; label: string }
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
value: string;
|
|
8
|
+
options: SongOption[];
|
|
9
|
+
onChange: (value: string) => void;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
id?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function SongSelect({ value, options, onChange, placeholder = '择…', disabled, id }: Props) {
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!open) return;
|
|
21
|
+
const close = (e: MouseEvent) => {
|
|
22
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
23
|
+
};
|
|
24
|
+
document.addEventListener('mousedown', close);
|
|
25
|
+
return () => document.removeEventListener('mousedown', close);
|
|
26
|
+
}, [open]);
|
|
27
|
+
|
|
28
|
+
const sel = options.find(o => o.value === value);
|
|
29
|
+
return (
|
|
30
|
+
<div className={'song-select' + (open ? ' open' : '')} ref={ref}>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
className="trigger"
|
|
34
|
+
id={id}
|
|
35
|
+
disabled={disabled}
|
|
36
|
+
onClick={() => setOpen(v => !v)}
|
|
37
|
+
aria-haspopup="listbox"
|
|
38
|
+
aria-expanded={open}
|
|
39
|
+
>
|
|
40
|
+
<span>{sel ? sel.label : placeholder}</span>
|
|
41
|
+
<svg className="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
42
|
+
<path d="m6 9 6 6 6-6" />
|
|
43
|
+
</svg>
|
|
44
|
+
</button>
|
|
45
|
+
{open && (
|
|
46
|
+
<div className="panel" role="listbox">
|
|
47
|
+
{options.map(o => (
|
|
48
|
+
<button
|
|
49
|
+
key={o.value}
|
|
50
|
+
type="button"
|
|
51
|
+
className={o.value === value ? 'sel' : ''}
|
|
52
|
+
onClick={() => { onChange(o.value); setOpen(false); }}
|
|
53
|
+
>
|
|
54
|
+
{o.label}
|
|
55
|
+
</button>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|