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,320 @@
1
+ // 九术模块工作台:输入面板 + 起占状态机 + 结果渲染 + AI 入口 + 免责声明
2
+ import { useParams, Link } from 'react-router-dom';
3
+ import { artById } from '@/data/arts';
4
+ import Disclaimer from '@/components/Disclaimer';
5
+ import { ResultPlaceholder } from '@/components/ResultCard';
6
+ import { useDivine } from '@/hooks/useDivine';
7
+ import { useAIInterpret } from '@/hooks/useAIInterpret';
8
+ import { artPairOf } from '@/arts/registry';
9
+ import { saveRecord } from '@/utils/recordStore';
10
+ import { downloadReport, apiDivine, type AIReport } from '@/services/api';
11
+ import ReportView from '@/components/ReportView';
12
+ import { currentUser, addSampleProfile } from '@/utils/userStore';
13
+ import { analyzeQuestionFit } from '@core/engine/questionFit';
14
+ import type { UserProfile } from '@/utils/userStore';
15
+ import { useEffect, useRef, useState } from 'react';
16
+ import { BaziPanel, BaziResult } from '@/components/arts/BaziArt';
17
+ import { ZiweiPanel, ZiweiResult } from '@/components/arts/ZiweiArt';
18
+ import { QimenPanel, QimenResultView } from '@/components/arts/QimenArt';
19
+ import { MeihuaPanel, MeihuaResult } from '@/components/arts/MeihuaArt';
20
+ import { LiuyaoPanel, LiuyaoResult } from '@/components/arts/LiuyaoArt';
21
+ import { LiurenPanel, LiurenResult } from '@/components/arts/LiurenArt';
22
+ import { XiaoliurenPanel, XiaoliurenResult } from '@/components/arts/XiaoliurenArt';
23
+ import { AstrologyPanel, AstrologyResult } from '@/components/arts/AstrologyArt';
24
+ import { TarotPanel, TarotResult } from '@/components/arts/TarotArt';
25
+ import type { ComponentType } from 'react';
26
+
27
+ interface ArtPair {
28
+ Panel: ComponentType<{ onDivine: (fn: () => unknown) => void }>;
29
+ Result: ComponentType<{ data: unknown }>;
30
+ placeholder: string;
31
+ }
32
+
33
+ const ART_PAIRS: Record<string, ArtPair> = {
34
+ bazi: { Panel: BaziPanel, Result: BaziResult, placeholder: '静候四柱 · 观五行流转' },
35
+ ziwei: { Panel: ZiweiPanel, Result: ZiweiResult, placeholder: '静候星布 · 观十四主星' },
36
+ qimen: { Panel: QimenPanel, Result: QimenResultView, placeholder: '静候局成 · 观九宫遁甲' },
37
+ meihua: { Panel: MeihuaPanel, Result: MeihuaResult, placeholder: '静候心动 · 观梅花开落' },
38
+ liuyao: { Panel: LiuyaoPanel, Result: LiuyaoResult, placeholder: '静候钱落 · 观六爻成象' },
39
+ liuren: { Panel: LiurenPanel, Result: LiurenResult, placeholder: '静候课起 · 观天地人盘' },
40
+ xiaoliuren: { Panel: XiaoliurenPanel, Result: XiaoliurenResult, placeholder: '静候指落 · 观掌诀玄机' },
41
+ astrology: { Panel: AstrologyPanel, Result: AstrologyResult, placeholder: '静候星布 · 观天穹为书' },
42
+ tarot: { Panel: TarotPanel, Result: TarotResult, placeholder: '静候牌启 · 观镜照本心' },
43
+ };
44
+
45
+ const PANEL_META: Record<string, { panelTitle: string; btn: string; btnSmall: string }> = {
46
+ bazi: { panelTitle: '布盘', btn: '排 盘', btnSmall: '布四柱 · 观五行 · 推十神' },
47
+ ziwei: { panelTitle: '布盘', btn: '布 盘', btnSmall: '安命宫 · 定紫微 · 布主星' },
48
+ qimen: { panelTitle: '起局', btn: '起 局', btnSmall: '定时 · 定局 · 布三奇六仪' },
49
+ meihua: { panelTitle: '起卦', btn: '起 卦', btnSmall: '定本卦 · 推互变 · 辨体用' },
50
+ liuyao: { panelTitle: '摇卦', btn: '掷 钱 成 卦', btnSmall: '六掷成卦 · 观变断象' },
51
+ liuren: { panelTitle: '起课', btn: '起 课', btnSmall: '立天地盘 · 布四课 · 取三传' },
52
+ xiaoliuren: { panelTitle: '占时', btn: '掐 指 一 算', btnSmall: '大安起月 · 月上起日 · 日上起时' },
53
+ astrology: { panelTitle: '布盘', btn: '布 星 图', btnSmall: '推行星黄经 · 布十二宫' },
54
+ tarot: { panelTitle: '问镜', btn: '洗 牌 抽 牌', btnSmall: '静心默问 · 凭心取牌' },
55
+ };
56
+
57
+ export default function ModulePage() {
58
+ const { artId } = useParams<{ artId: string }>();
59
+ const art = artId ? artById(artId) : undefined;
60
+ const { status, result, divineId, errorMsg: divineError, divine, reset } = useDivine();
61
+ const ai = useAIInterpret();
62
+
63
+ // 面板上报的出生档案(供存为档案)
64
+ const profileRef = useRef<UserProfile | null>(null);
65
+ const questionRef = useRef('');
66
+ const inputsRef = useRef<unknown>(null);
67
+ const profileIdRef = useRef('main');
68
+ const [fit, setFit] = useState<{ suitable: boolean | 'partial'; reason: string; suggestion: string } | null>(null);
69
+ const divineWithProfile = async (inputs: unknown, profile?: UserProfile, question?: string, profileId?: string) => {
70
+ // 新起占:重置 AI 解读状态,避免内容区残留上一档案的报告(含在途流式结果作废)
71
+ ai.reset();
72
+ if (profile) profileRef.current = profile;
73
+ inputsRef.current = inputs;
74
+ profileIdRef.current = profileId || 'main';
75
+ if (question !== undefined) questionRef.current = question;
76
+ // 问题适配性分析(无问题时不提示)
77
+ const q = question !== undefined ? question : ((document.getElementById('q-input') as HTMLInputElement)?.value || '');
78
+ setFit(q.trim() ? analyzeQuestionFit(art?.id || '', q.trim()) : null);
79
+ // 游客不允许:未登录直接提示
80
+ const user = currentUser();
81
+ if (!user) {
82
+ alert('请先入馆(登录)再起占');
83
+ return;
84
+ }
85
+ divine(async () => {
86
+ const r = await apiDivine(user.username, art?.id || '', inputs, profile || user.profile, q.trim() || undefined, profileId || 'main');
87
+ return { resultRaw: r.resultRaw, divineId: r.divineId };
88
+ });
89
+ };
90
+ const isProfileArt = ['bazi', 'ziwei', 'astrology'].includes(art?.id || '');
91
+ const exportReport = (rep: AIReport) => {
92
+ const lines = [
93
+ '# ' + (rep.title || '观微解读报告'),
94
+ '',
95
+ '> 术别:' + art.name + ' · ' + new Date().toLocaleString('zh-CN'),
96
+ '',
97
+ '## 总述',
98
+ '',
99
+ rep.overview,
100
+ '',
101
+ '## 原始解读',
102
+ '',
103
+ rep.rawReading?.summary || '',
104
+ ...(rep.rawReading?.keyPoints || []).map(k => '- ' + k),
105
+ '',
106
+ ...(rep.character ? ['## 命主性格', '', rep.character.summary || '', ...(rep.character.traits || []).map(t => '- **' + t.name + '**:' + t.desc), ''] : []),
107
+ ...(rep.lifeStages ? ['## 人生阶段', '', ...rep.lifeStages.map(s => '**' + s.stage + '(' + s.age + ')**:' + s.summary), ''] : []),
108
+ ...(rep.career?.summary ? ['## 学业 · 事业', '', rep.career.summary, '宜向:' + (rep.career.direction || ''), '建议:' + (rep.career.advice || ''), ''] : []),
109
+ ...(rep.love?.summary ? ['## 爱情', '', rep.love.summary, '建议:' + (rep.love.advice || ''), ''] : []),
110
+ ...(rep.wealth?.summary ? ['## 财富', '', rep.wealth.summary, '建议:' + (rep.wealth.advice || ''), ''] : []),
111
+ ...(rep.health?.summary ? ['## 健康', '', rep.health.summary, '建议:' + (rep.health.advice || ''), ''] : []),
112
+ ...(rep.situation ? ['## 当下局势', '', rep.situation, ''] : []),
113
+ ...(rep.trend ? ['## 发展趋势', '', rep.trend, ''] : []),
114
+ ...(rep.timing ? ['## 时机宜忌', '', rep.timing, ''] : []),
115
+ ...(rep.advice ? ['## 参详建议', '', ...rep.advice.split(/[;;]/).filter(Boolean).map(a => (a.trim() ? '- ' + a.trim() : '')), ''] : []),
116
+ ...(rep.conclusion ? ['## 结语', '', rep.conclusion, ''] : []),
117
+ '---',
118
+ rep.disclaimer || '',
119
+ ].filter(Boolean);
120
+ const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
121
+ const url = URL.createObjectURL(blob);
122
+ const a = document.createElement('a');
123
+ a.href = url;
124
+ a.download = (rep.title || '观微报告') + '.md';
125
+ a.click();
126
+ URL.revokeObjectURL(url);
127
+ };
128
+ const [saveName, setSaveName] = useState('');
129
+ const [saveMsg, setSaveMsg] = useState('');
130
+ const doSaveProfile = () => {
131
+ if (!profileRef.current) return;
132
+ const u = addSampleProfile(saveName, profileRef.current);
133
+ setSaveMsg(u ? '已存为示例档案「' + (saveName.trim() || '未名档案') + '」' : '请先入馆,方可存档');
134
+ setSaveName('');
135
+ };
136
+
137
+ // 后端起占记录的 divineId(AI 解读用)
138
+ const divineIdRef = useRef<string | null>(null);
139
+ divineIdRef.current = divineId;
140
+
141
+ // 起占成功 → 自动存档(StrictMode 防重复:以 result 引用去重)
142
+ const savedRef = useRef<unknown>(null);
143
+ useEffect(() => {
144
+ if (status === 'done' && result && art && savedRef.current !== result) {
145
+ savedRef.current = result;
146
+ const loc = profileRef.current?.location;
147
+ const prof = profileRef.current;
148
+ saveRecord({
149
+ artId: art.id,
150
+ createdAt: Date.now(),
151
+ result,
152
+ location: loc ? { lng: loc.lng, lat: loc.lat, province: loc.province, city: loc.city, district: loc.district } : undefined,
153
+ profileId: profileIdRef.current,
154
+ profile: prof ? { birthDate: prof.birthDate, birthTime: prof.birthTime, gender: prof.gender, location: prof.location } : null,
155
+ inputs: inputsRef.current,
156
+ question: questionRef.current || undefined,
157
+ });
158
+ }
159
+ }, [status, result, art]);
160
+
161
+ if (!art) {
162
+ return (
163
+ <div className="wrap" style={{ paddingTop: 'var(--sp-7)', textAlign: 'center' }}>
164
+ <div className="result-placeholder">
165
+ <div className="glyph">无</div>
166
+ <p>术无此名 · 请返册页再择</p>
167
+ </div>
168
+ <div className="mt-4"><Link className="btn-seal btn-ghost" to="/">返 册 页</Link></div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ const pair = artPairOf(art.id);
174
+ const meta = PANEL_META[art.id];
175
+ const PairPanel = pair?.Panel;
176
+ const PairResult = pair?.Result;
177
+
178
+ return (
179
+ <>
180
+ <section className="module-hero compact">
181
+ <div className="wrap">
182
+ <div className="mh-row">
183
+ <Link className="back-link" to="/" onClick={() => setTimeout(() => document.getElementById('home-arts')?.scrollIntoView(), 60)}>
184
+ ‹ 返册页
185
+ </Link>
186
+ <h2 className="module-title"><span className="cn">{art.name}</span><span className="mh-sub">{art.poem}</span></h2>
187
+ <p className="mh-kicker">{art.kicker}</p>
188
+ </div>
189
+ <details className="mh-intro">
190
+ <summary>术之源流 · 点击展卷</summary>
191
+ <p>{art.intro}</p>
192
+ <p className="source">{art.source}</p>
193
+ </details>
194
+ </div>
195
+ </section>
196
+
197
+ <div className="wrap">
198
+ <div className="altar">
199
+ <aside className="altar-panel" aria-label={`${art.name}输入`}>
200
+ <h3 className="panel-title">{meta?.panelTitle ?? '问占'}</h3>
201
+ {PairPanel ? <PairPanel onDivine={divineWithProfile} /> : (
202
+ <div className="result-placeholder" style={{ minHeight: 260, marginTop: 'var(--sp-3)' }}>
203
+ <div className="glyph" style={{ fontSize: '2rem' }}>{art.glyph}</div>
204
+ <p>此术之具,静候铺陈</p>
205
+ </div>
206
+ )}
207
+ </aside>
208
+ <div className="altar-result stagger">
209
+ {fit && fit.suitable !== true && (
210
+ <div className="fit-note" role="note">
211
+ <strong>{fit.suitable === 'partial' ? '适问之辨 · 部分相契' : '适问之辨 · 不甚相契'}</strong>
212
+ <p>{fit.reason}</p>
213
+ {fit.suggestion && <p className="fit-suggest">✦ {fit.suggestion}</p>}
214
+ </div>
215
+ )}
216
+ {status === 'idle' && <ResultPlaceholder glyph={art.glyph} text={pair?.placeholder ?? '静候卦象 · 观微知著'} />}
217
+ {status === 'casting' && (
218
+ <div className="result-placeholder">
219
+ <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)' }}>{art.glyph}</div></div>
220
+ <p>凝神静气 · 卦象将成</p>
221
+ </div>
222
+ )}
223
+ {status === 'done' && result && PairResult && <PairResult data={result} />}
224
+ {status === 'error' && (
225
+ <ResultPlaceholder glyph="滞" text={divineError || '推演未应机 · 请稍后再试'} />
226
+ )}
227
+ {status === 'done' && result && (
228
+ <div className="result-card mt-3">
229
+ <h3>AI 深度解读</h3>
230
+ {ai.status === 'idle' && (
231
+ <div className="result-text">
232
+ <p>排盘既成,可召 AI 以现代语言为君详解;引经据典处,另依古籍话术标注出处。</p>
233
+ <button
234
+ className="btn-seal"
235
+ style={{ marginTop: 'var(--sp-2)', fontSize: '.9rem', padding: '.5rem 1.4rem' }}
236
+ onClick={() => {
237
+ console.log('[观微 AI] 点击召请,artId=', art.id, 'question=', questionRef.current, 'API_BASE=', (window as any).__API_BASE__ || '/api');
238
+ ai.interpret({ artId: art.id, divineId: divineIdRef.current || undefined, question: questionRef.current || undefined, profile: profileRef.current || undefined, reportMode: true, fit: fit || undefined });
239
+ }}
240
+ >
241
+ 召 AI 成报告
242
+ </button>
243
+ </div>
244
+ )}
245
+ {ai.status === 'streaming' && (
246
+ <div className="ai-streaming">
247
+ <div className="ai-stream-head">
248
+ <span className="ai-spinner" aria-hidden="true" />
249
+ <span className="tag-cool">AI 依术呈卷中……(Skills 编排 · 分章成文)</span>
250
+ </div>
251
+ <p className="tiny muted" style={{ marginTop: '.3rem' }}>已生成 <span className="ai-count" style={{ fontVariantNumeric: 'tabular-nums', color: 'var(--celadon-deep)', fontWeight: 600 }}>{ai.text.length}</span> 字 · 长文约需 20-60 秒,请稍候</p>
252
+ <div className="ai-stream-body" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 48, padding: 'var(--sp-2) 0' }}>
253
+ <p className="muted" style={{ fontSize: 'var(--fs-sm)', letterSpacing: '.3em', animation: 'pulse-soft 1.8s ease-in-out infinite' }}>静候呈卷 · 成文后即展全篇</p>
254
+ </div>
255
+ </div>
256
+ )}
257
+ {ai.status === 'done' && (
258
+ <div className="result-text">
259
+ {ai.truncated && (
260
+ <div className="ai-error" style={{ marginBottom: 'var(--sp-2)' }}>
261
+ <p className="tag-hot"><strong>内容截断</strong>:本次呈卷因篇幅限制未能完整生成,可重试。</p>
262
+ </div>
263
+ )}
264
+ {ai.quality === 'poor' && (
265
+ <div className="ai-error" style={{ marginBottom: 'var(--sp-2)' }}>
266
+ <p className="tag-hot"><strong>内容不完整</strong>:本次解读部分章节缺失,可重试。</p>
267
+ </div>
268
+ )}
269
+ {ai.report ? (
270
+ <ReportView report={ai.report} artName={art.name} onExport={() => exportReport(ai.report!)} />
271
+ ) : ai.sections.length > 0 ? ai.sections.map((s, i) => (
272
+ <div key={i} style={{ marginBottom: 'var(--sp-2)' }}>
273
+ <p className="tag-cool" style={{ marginBottom: '.3rem' }}><strong>{s.title}</strong></p>
274
+ <p style={{ whiteSpace: 'pre-wrap' }}>{s.content}</p>
275
+ </div>
276
+ )) : ai.text ? <p style={{ whiteSpace: 'pre-wrap' }}>{ai.text}</p> : null}
277
+ {!ai.report && <p className="tiny muted" style={{ marginTop: 'var(--sp-2)', borderTop: '1px solid var(--line-soft)', paddingTop: '.6rem' }}>凡占问所得,仅供修身养性、怡情遣兴之用,不构成决策依据。</p>}
278
+ </div>
279
+ )}
280
+ {ai.status === 'error' && (
281
+ <div className="ai-error">
282
+ <p className="tag-hot"><strong>AI 未应机</strong>:{ai.errorMsg || 'AI 解读暂未应机,请稍后再试。'}</p>
283
+ {ai.errorCode === 'AI_UNCONFIGURED' ? (
284
+ <div className="ai-config-hint" style={{ marginTop: '.5rem', padding: '.6rem .8rem', border: '1px dashed var(--cinnabar)', borderRadius: 'var(--r-sm)' }}>
285
+ <p className="tiny"><strong>尚未配置 AI 服务的 API Key</strong>——配置后即可生成 AI 报告:</p>
286
+ <ol className="tiny muted" style={{ margin: '.4rem 0 0 1.2rem', lineHeight: 1.8 }}>
287
+ <li>终端进入项目目录,运行 <code style={{ background: 'rgba(0,0,0,.06)', padding: '0 .3rem' }}>./scripts/setup.sh --key 你的APIKey</code>(Windows:<code style={{ background: 'rgba(0,0,0,.06)', padding: '0 .3rem' }}>scripts\setup.bat --key 你的APIKey</code>)</li>
288
+ <li>Key 申请入口与详细步骤见 <a href="https://github.com/RubyCcll/guanwei#%F0%9F%94%91-%E8%8E%B7%E5%8F%96-api-key" target="_blank" rel="noreferrer">README · 获取 API Key</a>(DeepSeek 等 5 家任选)</li>
289
+ <li>配置后重启服务即可;也可运行 <code style={{ background: 'rgba(0,0,0,.06)', padding: '0 .3rem' }}>guanwei doctor</code> 检查配置</li>
290
+ </ol>
291
+ </div>
292
+ ) : (
293
+ <p className="tiny muted">古法规则解读为备,可先依上列排盘自行观照;若为 AI 服务异常,可于服务端检查 LLM 配置后重试。</p>
294
+ )}
295
+ <div className="btn-row" style={{ marginTop: 'var(--sp-2)' }}>
296
+ <button className="btn-seal" style={{ fontSize: '.82rem', padding: '.4rem 1.1rem' }} onClick={() => ai.interpret({ artId: art.id, resultRaw: result, question: questionRef.current || undefined, profile: profileRef.current || undefined, reportMode: true, fit: fit || undefined })}>重 试</button>
297
+ <button className="btn-seal btn-ghost" style={{ fontSize: '.82rem', padding: '.4rem 1.1rem' }} onClick={ai.reset}>收 起</button>
298
+ </div>
299
+ </div>
300
+ )}
301
+ {isProfileArt && currentUser() && (
302
+ <div className="no-print" style={{ marginTop: 'var(--sp-3)', paddingTop: 'var(--sp-2)', borderTop: '1px solid var(--line-soft)' }}>
303
+ <p className="tiny muted" style={{ marginBottom: '.4rem' }}>存此排盘入馆为示例档案(主档案唯一,此为示例):</p>
304
+ <div className="btn-row" style={{ gap: '.5rem' }}>
305
+ <input className="input-line" style={{ maxWidth: 240 }} placeholder="档案名,如:1995 生辰" value={saveName} onChange={e => setSaveName(e.target.value)} />
306
+ <button className="btn-seal" style={{ fontSize: '.8rem', padding: '.35rem 1rem' }} onClick={doSaveProfile}>存 档</button>
307
+ </div>
308
+ {saveMsg && <p className={"tiny mt-1 " + (saveMsg.includes('已存') ? 'tag-cool' : 'tag-hot')}>{saveMsg}</p>}
309
+ </div>
310
+ )}
311
+ <button className="btn-seal btn-ghost no-print" style={{ marginTop: 'var(--sp-3)', fontSize: '.9rem', padding: '.5rem 1.4rem' }} onClick={() => { ai.reset(); reset(); }}>再 起 一 占</button>
312
+ </div>
313
+ )}
314
+ </div>
315
+ </div>
316
+ <Disclaimer />
317
+ </div>
318
+ </>
319
+ );
320
+ }
@@ -0,0 +1,230 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useNavigate, useParams } from 'react-router-dom';
3
+ import { Plus, Trash2, Save, ArrowLeft, GripVertical } from 'lucide-react';
4
+ import type { Spread, SpreadPosition } from '@/types';
5
+ import { saveCustomSpread, getCustomSpreads, deleteCustomSpread } from '@/utils/spreadStorage';
6
+
7
+ export default function SpreadEditor() {
8
+ const navigate = useNavigate();
9
+ const { id } = useParams();
10
+ const isEdit = !!id;
11
+
12
+ const [name, setName] = useState('');
13
+ const [description, setDescription] = useState('');
14
+ const [positions, setPositions] = useState<SpreadPosition[]>([
15
+ { id: 0, name: '现状', description: '当前状态', x: 50, y: 50 },
16
+ ]);
17
+ const [draggingIdx, setDraggingIdx] = useState<number | null>(null);
18
+
19
+ useEffect(() => {
20
+ if (isEdit && id) {
21
+ const custom = getCustomSpreads();
22
+ const spread = custom.find(s => s.id === id);
23
+ if (spread) {
24
+ setName(spread.name);
25
+ setDescription(spread.description);
26
+ setPositions(spread.positions);
27
+ }
28
+ }
29
+ }, [id, isEdit]);
30
+
31
+ const addPosition = () => {
32
+ if (positions.length >= 12) return;
33
+ const newId = positions.length;
34
+ const cols = Math.ceil(Math.sqrt(positions.length + 1));
35
+ const row = Math.floor(positions.length / cols);
36
+ const col = positions.length % cols;
37
+ setPositions([
38
+ ...positions,
39
+ {
40
+ id: newId,
41
+ name: `位置${newId + 1}`,
42
+ description: '请输入描述',
43
+ x: 15 + (col * 70 / (cols - 1 || 1)),
44
+ y: 15 + (row * 70 / (cols - 1 || 1)),
45
+ },
46
+ ]);
47
+ };
48
+
49
+ const updatePosition = (idx: number, field: keyof SpreadPosition, value: string | number) => {
50
+ setPositions(positions.map((p, i) => i === idx ? { ...p, [field]: value } : p));
51
+ };
52
+
53
+ const removePosition = (idx: number) => {
54
+ if (positions.length <= 2) return;
55
+ setPositions(positions.filter((_, i) => i !== idx).map((p, i) => ({ ...p, id: i })));
56
+ };
57
+
58
+ const handleSave = () => {
59
+ if (!name.trim() || positions.length < 2) return;
60
+
61
+ const spread: Spread = {
62
+ id: isEdit && id ? id : `custom-${Date.now()}`,
63
+ name: name.trim(),
64
+ description: description.trim() || '我的自定义牌阵',
65
+ positions,
66
+ category: 'custom',
67
+ isCustom: true,
68
+ scene: ['general'],
69
+ };
70
+
71
+ saveCustomSpread(spread);
72
+ navigate('/spread-library');
73
+ };
74
+
75
+ const handleDelete = () => {
76
+ if (!isEdit || !id) return;
77
+ if (confirm('确定删除这个牌阵?')) {
78
+ deleteCustomSpread(id);
79
+ navigate('/spread-library');
80
+ }
81
+ };
82
+
83
+ const handlePositionDrag = (e: React.MouseEvent<HTMLDivElement>) => {
84
+ if (draggingIdx === null) return;
85
+ const rect = e.currentTarget.getBoundingClientRect();
86
+ const x = ((e.clientX - rect.left) / rect.width) * 100;
87
+ const y = ((e.clientY - rect.top) / rect.height) * 100;
88
+ setPositions(positions.map((p, i) => i === draggingIdx ? { ...p, x: Math.max(5, Math.min(95, x)), y: Math.max(5, Math.min(95, y)) } : p));
89
+ };
90
+
91
+ return (
92
+ <div className="relative min-h-screen pt-20 pb-16 px-4">
93
+ <div className="max-w-5xl mx-auto">
94
+ <button
95
+ onClick={() => navigate(-1)}
96
+ className="inline-flex items-center gap-2 text-stardust/60 hover:text-ancient text-sm mb-6"
97
+ >
98
+ <ArrowLeft className="w-4 h-4" />
99
+ 返回
100
+ </button>
101
+
102
+ <h1 className="font-display text-3xl text-ancient tracking-wider mb-8">
103
+ {isEdit ? '编辑牌阵' : '创建自定义牌阵'}
104
+ </h1>
105
+
106
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
107
+ {/* Editor Form */}
108
+ <div className="space-y-6">
109
+ <div className="glass-panel p-6">
110
+ <div className="space-y-4">
111
+ <div>
112
+ <label className="block text-ancient/80 text-sm mb-2">牌阵名称</label>
113
+ <input
114
+ type="text"
115
+ value={name}
116
+ onChange={(e) => setName(e.target.value)}
117
+ placeholder="例:感情发展三步"
118
+ className="w-full bg-void/50 border border-ancient/20 rounded px-3 py-2 text-stardust placeholder-stardust/30 focus:border-ancient/60 focus:outline-none"
119
+ />
120
+ </div>
121
+ <div>
122
+ <label className="block text-ancient/80 text-sm mb-2">牌阵描述</label>
123
+ <textarea
124
+ value={description}
125
+ onChange={(e) => setDescription(e.target.value)}
126
+ placeholder="描述这个牌阵的用途..."
127
+ className="w-full bg-void/50 border border-ancient/20 rounded px-3 py-2 text-stardust placeholder-stardust/30 focus:border-ancient/60 focus:outline-none resize-none h-20"
128
+ />
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ <div className="glass-panel p-6">
134
+ <div className="flex items-center justify-between mb-4">
135
+ <h3 className="font-display text-ancient text-sm tracking-wider">牌位({positions.length}/12)</h3>
136
+ <button
137
+ onClick={addPosition}
138
+ disabled={positions.length >= 12}
139
+ className="inline-flex items-center gap-1 px-3 py-1 bg-ancient/20 border border-ancient/50 text-ancient rounded-sm text-xs hover:bg-ancient/30 transition-all disabled:opacity-30"
140
+ >
141
+ <Plus className="w-3 h-3" />
142
+ 增加
143
+ </button>
144
+ </div>
145
+
146
+ <div className="space-y-3 max-h-96 overflow-y-auto">
147
+ {positions.map((pos, idx) => (
148
+ <div key={pos.id} className="bg-abyss/40 border border-ancient/10 rounded p-3 space-y-2">
149
+ <div className="flex items-center justify-between">
150
+ <span className="text-xs text-ancient/60">#{idx + 1}</span>
151
+ <button
152
+ onClick={() => removePosition(idx)}
153
+ disabled={positions.length <= 2}
154
+ className="text-stardust/40 hover:text-crimson disabled:opacity-30"
155
+ >
156
+ <Trash2 className="w-3 h-3" />
157
+ </button>
158
+ </div>
159
+ <input
160
+ type="text"
161
+ value={pos.name}
162
+ onChange={(e) => updatePosition(idx, 'name', e.target.value)}
163
+ placeholder="牌位名称"
164
+ className="w-full bg-void/50 border border-ancient/20 rounded px-2 py-1 text-sm text-stardust focus:border-ancient/60 focus:outline-none"
165
+ />
166
+ <input
167
+ type="text"
168
+ value={pos.description}
169
+ onChange={(e) => updatePosition(idx, 'description', e.target.value)}
170
+ placeholder="牌位含义"
171
+ className="w-full bg-void/50 border border-ancient/20 rounded px-2 py-1 text-xs text-stardust focus:border-ancient/60 focus:outline-none"
172
+ />
173
+ </div>
174
+ ))}
175
+ </div>
176
+ </div>
177
+
178
+ <div className="flex gap-3">
179
+ <button
180
+ onClick={handleSave}
181
+ disabled={!name.trim() || positions.length < 2}
182
+ className="flex-1 inline-flex items-center justify-center gap-2 px-6 py-3 bg-ancient/20 border border-ancient/50 text-ancient hover:bg-ancient/30 transition-all rounded-sm disabled:opacity-30"
183
+ >
184
+ <Save className="w-4 h-4" />
185
+ {isEdit ? '保存修改' : '保存牌阵'}
186
+ </button>
187
+ {isEdit && (
188
+ <button
189
+ onClick={handleDelete}
190
+ className="inline-flex items-center gap-2 px-4 py-3 border border-crimson/40 text-crimson/70 hover:border-crimson hover:text-crimson transition-all rounded-sm"
191
+ >
192
+ <Trash2 className="w-4 h-4" />
193
+ 删除
194
+ </button>
195
+ )}
196
+ </div>
197
+ </div>
198
+
199
+ {/* Layout Preview */}
200
+ <div className="glass-panel p-6">
201
+ <h3 className="font-display text-ancient text-sm tracking-wider mb-4">布局预览</h3>
202
+ <p className="text-xs text-stardust/40 mb-4">点击下方画布的牌位可拖拽调整位置</p>
203
+ <div
204
+ className="relative w-full bg-void/40 border border-ancient/20 rounded-lg"
205
+ style={{ aspectRatio: '1', minHeight: '300px' }}
206
+ onMouseMove={handlePositionDrag}
207
+ onMouseUp={() => setDraggingIdx(null)}
208
+ onMouseLeave={() => setDraggingIdx(null)}
209
+ >
210
+ {positions.map((pos, idx) => (
211
+ <div
212
+ key={pos.id}
213
+ onMouseDown={() => setDraggingIdx(idx)}
214
+ className="absolute -translate-x-1/2 -translate-y-1/2 cursor-move group"
215
+ style={{ left: `${pos.x}%`, top: `${pos.y}%` }}
216
+ >
217
+ <div className="w-16 h-24 bg-abyss border-2 border-ancient/60 rounded flex flex-col items-center justify-center text-center p-1 group-hover:border-ancient transition-colors">
218
+ <GripVertical className="w-3 h-3 text-ancient/40 absolute -top-3 opacity-0 group-hover:opacity-100" />
219
+ <span className="text-[9px] text-ancient/60">#{idx + 1}</span>
220
+ <span className="text-[10px] text-stardust truncate w-full">{pos.name}</span>
221
+ </div>
222
+ </div>
223
+ ))}
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ );
230
+ }
@@ -0,0 +1,105 @@
1
+ // 自创牌阵:创建/编辑自定义塔罗牌阵(2-10 位,localStorage)
2
+ import { useState } from 'react';
3
+ import { Link, useNavigate } from 'react-router-dom';
4
+ import { getCustomTarotSpreads, saveCustomTarotSpread, deleteCustomTarotSpread } from '@core/data/tarotSpreads';
5
+ import { useConfirm } from '@/components/SongDialog';
6
+
7
+ export default function SpreadEditorPage() {
8
+ const navigate = useNavigate();
9
+ const confirm = useConfirm();
10
+ const [name, setName] = useState('');
11
+ const [description, setDescription] = useState('');
12
+ const [positions, setPositions] = useState<{ id: number; name: string; description: string }[]>([
13
+ { id: 0, name: '现况', description: '当下的状况' },
14
+ { id: 1, name: '指引', description: '可为之径' },
15
+ ]);
16
+
17
+ const addPos = () => {
18
+ if (positions.length >= 10) return;
19
+ setPositions([...positions, { id: positions.length, name: '位' + (positions.length + 1), description: '请书其义' }]);
20
+ };
21
+
22
+ const removePos = (i: number) => {
23
+ if (positions.length <= 2) return;
24
+ setPositions(positions.filter((_, j) => j !== i).map((p, j) => ({ ...p, id: j })));
25
+ };
26
+
27
+ const upd = (i: number, field: 'name' | 'description', v: string) => {
28
+ setPositions(positions.map((p, j) => j === i ? { ...p, [field]: v } : p));
29
+ };
30
+
31
+ const save = () => {
32
+ if (!name.trim() || positions.length < 2) return;
33
+ const spread = {
34
+ id: 'custom-' + Date.now(),
35
+ name: name.trim(),
36
+ description: description.trim() || '君之自创牌阵',
37
+ positions,
38
+ isCustom: true,
39
+ };
40
+ saveCustomTarotSpread(spread);
41
+ navigate('/art/tarot');
42
+ };
43
+
44
+ const clearAll = async () => {
45
+ const custom = getCustomTarotSpreads();
46
+ if (!custom.length) return;
47
+ const ok = await confirm('将尽除所创牌阵,共 ' + custom.length + ' 个,是否确然?', { title: '慎问', confirmText: '尽 除', danger: true });
48
+ if (ok) {
49
+ custom.forEach(s => deleteCustomTarotSpread(s.id));
50
+ navigate('/art/tarot');
51
+ }
52
+ };
53
+
54
+ const customList = getCustomTarotSpreads();
55
+
56
+ return (
57
+ <div className="wrap" style={{ paddingTop: 'var(--sp-7)', paddingBottom: 'var(--sp-6)', maxWidth: 760 }}>
58
+ <Link className="back-link" to="/art/tarot">‹ 返问镜</Link>
59
+ <div className="module-kicker">镜鉴 · 自创</div>
60
+ <div className="module-title-row">
61
+ <h2 className="module-title"><span className="cn">自创牌阵</span>布位立义</h2>
62
+ </div>
63
+ <p className="module-intro">为所问之事,自定牌位之序与其义。二至十位皆可。</p>
64
+
65
+ <div className="altar-panel" style={{ position: 'static', marginTop: 'var(--sp-4)' }}>
66
+ <div className="field"><label>牌阵名</label>
67
+ <input className="input-line" placeholder="例:吾之三问" value={name} onChange={e => setName(e.target.value)} /></div>
68
+ <div className="field"><label>牌阵述</label>
69
+ <input className="input-line" placeholder="此阵为何而设…" value={description} onChange={e => setDescription(e.target.value)} /></div>
70
+ <div className="field"><label>牌位({positions.length}/10)</label>
71
+ {positions.map((p, i) => (
72
+ <div key={p.id} style={{ display: 'flex', gap: '.5rem', marginBottom: '.5rem', alignItems: 'center' }}>
73
+ <span className="tiny muted" style={{ width: '1.6rem', textAlign: 'right' }}>#{i + 1}</span>
74
+ <input className="input-line" style={{ maxWidth: 180 }} placeholder="位名" value={p.name} onChange={e => upd(i, 'name', e.target.value)} />
75
+ <input className="input-line" placeholder="位义" value={p.description} onChange={e => upd(i, 'description', e.target.value)} />
76
+ <button className="pill hot" style={{ cursor: 'pointer', flexShrink: 0 }} onClick={() => removePos(i)} disabled={positions.length <= 2}>删</button>
77
+ </div>
78
+ ))}
79
+ <button className="btn-seal btn-ghost" style={{ fontSize: '.8rem', padding: '.35rem 1rem' }} onClick={addPos} disabled={positions.length >= 10}>增 位</button>
80
+ </div>
81
+ <div style={{ display: 'flex', gap: '.8rem', marginTop: 'var(--sp-3)' }}>
82
+ <button className="btn-divine" style={{ width: 'auto', padding: '.7rem 2rem', marginTop: 0 }} onClick={save}>存 阵</button>
83
+ <button className="btn-seal btn-ghost" style={{ fontSize: '.85rem', padding: '.5rem 1.2rem' }} onClick={clearAll}>清 所 创</button>
84
+ </div>
85
+ </div>
86
+
87
+ {customList.length > 0 && (
88
+ <div className="mt-4">
89
+ <h3 className="tag-cool" style={{ letterSpacing: '.25em', marginBottom: 'var(--sp-2)' }}>已创牌阵</h3>
90
+ {customList.map(s => (
91
+ <div className="result-card" key={s.id} style={{ marginBottom: '.8rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
92
+ <div>
93
+ <strong>{s.name}</strong> <span className="tiny muted">· {s.positions.length} 位 · {s.description}</span>
94
+ </div>
95
+ <button className="pill hot" style={{ cursor: 'pointer' }} onClick={async () => {
96
+ const ok = await confirm('除「' + s.name + '」此阵?', { title: '慎问', confirmText: '除 之', danger: true });
97
+ if (ok) { deleteCustomTarotSpread(s.id); navigate(0); }
98
+ }}>删</button>
99
+ </div>
100
+ ))}
101
+ </div>
102
+ )}
103
+ </div>
104
+ );
105
+ }