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,738 @@
1
+ import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { Sparkles, Shuffle, Edit3, ChevronDown, ChevronUp, RotateCcw, Search, Plus, Library, X, Play, Pause, SkipForward, SkipBack } from 'lucide-react';
4
+ import type { QuestionCategory, Spread, DrawnCard, TarotCard as TarotCardType } from '@/types';
5
+ import { defaultSpreads } from '@/data/spreads';
6
+ import { tarotCards } from '@/data/tarotCards';
7
+ import { drawCards, generateInterpretation } from '@/utils/tarotEngine';
8
+ import { generateComboResult } from '@/utils/comboEngine';
9
+ import { saveRecord } from '@/utils/storage';
10
+ import { getCustomSpreads } from '@/utils/spreadStorage';
11
+ import { api } from '@/services/api';
12
+ import { useDivinationStore } from '@/stores/useDivinationStore';
13
+ import TarotCard from '@/components/TarotCard';
14
+ import SpreadLibrary from './SpreadLibrary';
15
+
16
+ const categories: { value: QuestionCategory; label: string }[] = [
17
+ { value: 'love', label: '爱情' },
18
+ { value: 'career', label: '事业' },
19
+ { value: 'wealth', label: '财运' },
20
+ { value: 'health', label: '健康' },
21
+ { value: 'study', label: '学业' },
22
+ { value: 'family', label: '家庭' },
23
+ { value: 'spiritual', label: '灵性' },
24
+ { value: 'general', label: '综合' },
25
+ ];
26
+
27
+ const comboMethods = [
28
+ { value: 'xiaoliuren', label: '小六壬' },
29
+ { value: 'liuyao', label: '六爻' },
30
+ { value: 'meihua', label: '梅花易数' },
31
+ { value: 'daliuren', label: '大六壬' },
32
+ { value: 'qimen', label: '奇门遁甲' },
33
+ ];
34
+
35
+ const SUIT_TABS = [
36
+ { value: 'all', label: '全部' },
37
+ { value: 'major', label: '大阿卡纳' },
38
+ { value: 'wands', label: '权杖' },
39
+ { value: 'cups', label: '圣杯' },
40
+ { value: 'swords', label: '宝剑' },
41
+ { value: 'pentacles', label: '星币' },
42
+ ];
43
+
44
+ function TypewriterText({ text, speed = 50, onComplete }: { text: string; speed?: number; onComplete?: () => void }) {
45
+ const [displayText, setDisplayText] = useState('');
46
+ const [isComplete, setIsComplete] = useState(false);
47
+ const indexRef = useRef(0);
48
+
49
+ useEffect(() => {
50
+ setDisplayText('');
51
+ setIsComplete(false);
52
+ indexRef.current = 0;
53
+
54
+ const timer = setInterval(() => {
55
+ if (indexRef.current < text.length) {
56
+ setDisplayText(text.slice(0, indexRef.current + 1));
57
+ indexRef.current++;
58
+ } else {
59
+ clearInterval(timer);
60
+ setIsComplete(true);
61
+ onComplete?.();
62
+ }
63
+ }, speed);
64
+
65
+ return () => clearInterval(timer);
66
+ }, [text, speed, onComplete]);
67
+
68
+ return (
69
+ <span className={isComplete ? '' : 'border-r border-ancient/50'}>
70
+ {displayText}
71
+ </span>
72
+ );
73
+ }
74
+
75
+ export default function TarotPage() {
76
+ const navigate = useNavigate();
77
+ const { setCurrentReading, setComboResult, isComboEnabled, setIsComboEnabled } = useDivinationStore();
78
+
79
+ const [question, setQuestion] = useState('');
80
+ const [category, setCategory] = useState<QuestionCategory>('general');
81
+ const [allSpreads, setAllSpreads] = useState<Spread[]>(defaultSpreads);
82
+ const [selectedSpread, setSelectedSpread] = useState<Spread>(defaultSpreads[0]);
83
+ const [drawnCards, setDrawnCards] = useState<DrawnCard[]>([]);
84
+ const [isDrawing, setIsDrawing] = useState(false);
85
+ const [isInterpreting, setIsInterpreting] = useState(false);
86
+ const [showResult, setShowResult] = useState(false);
87
+ const [interpretation, setInterpretation] = useState<any>(null);
88
+ const [selectedComboMethod, setSelectedComboMethod] = useState('xiaoliuren');
89
+ const [comboResultLocal, setComboResultLocal] = useState<any>(null);
90
+ const [manualMode, setManualMode] = useState(false);
91
+ const [manualCards, setManualCards] = useState<{ cardId: number; isReversed: boolean }[]>([]);
92
+ const [expandedSection, setExpandedSection] = useState<string | null>(null);
93
+ const [showLibrary, setShowLibrary] = useState(false);
94
+ const [cardSearch, setCardSearch] = useState('');
95
+ const [activeSuit, setActiveSuit] = useState('all');
96
+
97
+ const [currentSectionIndex, setCurrentSectionIndex] = useState(0);
98
+ const [isPlaying, setIsPlaying] = useState(false);
99
+ const [displaySpeed, setDisplaySpeed] = useState(50);
100
+ const [completedSections, setCompletedSections] = useState<number[]>([]);
101
+
102
+ useEffect(() => {
103
+ setAllSpreads([...defaultSpreads, ...getCustomSpreads()]);
104
+ }, [showLibrary]);
105
+
106
+ const filteredCards = useMemo(() => {
107
+ let cards = tarotCards;
108
+ if (activeSuit === 'major') {
109
+ cards = cards.filter(c => c.arcana === 'major');
110
+ } else if (activeSuit !== 'all') {
111
+ cards = cards.filter(c => c.suit === activeSuit);
112
+ }
113
+ if (cardSearch) {
114
+ const q = cardSearch.toLowerCase();
115
+ cards = cards.filter(c => c.name.toLowerCase().includes(q) || c.nameEn.toLowerCase().includes(q) || c.keywords.some(k => k.includes(q)));
116
+ }
117
+ return cards;
118
+ }, [activeSuit, cardSearch]);
119
+
120
+ useEffect(() => {
121
+ if (isPlaying && interpretation && currentSectionIndex < interpretation.sections.length) {
122
+ const section = interpretation.sections[currentSectionIndex];
123
+ const duration = (section.content.length * displaySpeed) + 2000;
124
+
125
+ const timer = setTimeout(() => {
126
+ setCompletedSections(prev => [...prev, currentSectionIndex]);
127
+ if (currentSectionIndex < interpretation.sections.length - 1) {
128
+ setTimeout(() => {
129
+ setCurrentSectionIndex(prev => prev + 1);
130
+ }, 1000);
131
+ } else {
132
+ setIsPlaying(false);
133
+ }
134
+ }, duration);
135
+
136
+ return () => clearTimeout(timer);
137
+ }
138
+ }, [isPlaying, currentSectionIndex, interpretation, displaySpeed]);
139
+
140
+ const handleInterpret = useCallback(async (drawMode: 'random' | 'manual') => {
141
+ if (!question.trim()) return;
142
+ if (drawMode === 'manual' && manualCards.length !== selectedSpread.positions.length) return;
143
+
144
+ setIsDrawing(true);
145
+ setIsInterpreting(false);
146
+ setShowResult(false);
147
+ setComboResultLocal(null);
148
+ setCurrentSectionIndex(0);
149
+ setCompletedSections([]);
150
+ setIsPlaying(false);
151
+
152
+ setTimeout(async () => {
153
+ setIsDrawing(false);
154
+ setIsInterpreting(true);
155
+
156
+ let cards: DrawnCard[];
157
+ try {
158
+ if (drawMode === 'random') {
159
+ const drawResult = await api.tarot.draw(selectedSpread.id, selectedSpread.isCustom ? selectedSpread : undefined);
160
+ cards = drawResult.cards;
161
+ } else {
162
+ cards = manualCards.map((mc, index) => ({
163
+ cardId: mc.cardId,
164
+ isReversed: mc.isReversed,
165
+ positionId: selectedSpread.positions[index].id,
166
+ }));
167
+ }
168
+ setDrawnCards(cards);
169
+
170
+ const result = await api.tarot.interpret(cards, selectedSpread, question, category);
171
+ setInterpretation(result);
172
+ setShowResult(true);
173
+ setIsInterpreting(false);
174
+
175
+ const reading = {
176
+ question,
177
+ category,
178
+ spreadId: selectedSpread.id,
179
+ cards,
180
+ interpretation: result.fullText,
181
+ };
182
+ setCurrentReading(reading);
183
+
184
+ saveRecord({
185
+ id: Date.now().toString(),
186
+ type: 'tarot',
187
+ question,
188
+ category,
189
+ spreadId: selectedSpread.id,
190
+ cards,
191
+ interpretation: result.fullText,
192
+ timestamp: Date.now(),
193
+ });
194
+ } catch (error) {
195
+ console.error('API 调用失败:', error);
196
+ setIsInterpreting(false);
197
+ }
198
+ }, 1500);
199
+ }, [question, manualCards, selectedSpread, category, setCurrentReading]);
200
+
201
+ const handleCombo = useCallback(() => {
202
+ const result = generateComboResult(selectedComboMethod);
203
+ setComboResultLocal(result);
204
+ setComboResult(result);
205
+ }, [selectedComboMethod, setComboResult]);
206
+
207
+ const reset = () => {
208
+ setQuestion('');
209
+ setDrawnCards([]);
210
+ setShowResult(false);
211
+ setInterpretation(null);
212
+ setComboResultLocal(null);
213
+ setIsComboEnabled(false);
214
+ setManualMode(false);
215
+ setManualCards([]);
216
+ setExpandedSection(null);
217
+ setCurrentSectionIndex(0);
218
+ setCompletedSections([]);
219
+ setIsPlaying(false);
220
+ };
221
+
222
+ const toggleManualCard = (cardId: number) => {
223
+ if (manualCards.length >= selectedSpread.positions.length) {
224
+ setManualCards([...manualCards.slice(0, -1), { cardId, isReversed: false }]);
225
+ } else {
226
+ setManualCards([...manualCards, { cardId, isReversed: false }]);
227
+ }
228
+ };
229
+
230
+ const toggleManualReversed = (idx: number) => {
231
+ setManualCards(manualCards.map((c, i) => i === idx ? { ...c, isReversed: !c.isReversed } : c));
232
+ };
233
+
234
+ const removeManualCard = (idx: number) => {
235
+ setManualCards(manualCards.filter((_, i) => i !== idx));
236
+ };
237
+
238
+ const handleSelectSpread = (spread: Spread) => {
239
+ setSelectedSpread(spread);
240
+ setShowLibrary(false);
241
+ setManualCards([]);
242
+ };
243
+
244
+ const goToSection = (index: number) => {
245
+ setCurrentSectionIndex(index);
246
+ setIsPlaying(false);
247
+ };
248
+
249
+ const renderInterpretationSection = (section: any, index: number) => {
250
+ const isCurrent = index === currentSectionIndex;
251
+ const isCompleted = completedSections.includes(index);
252
+ const isActive = isCurrent || isCompleted;
253
+
254
+ return (
255
+ <div
256
+ key={section.title}
257
+ className={`mb-4 transition-all duration-500 ${
258
+ isActive ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'
259
+ }`}
260
+ >
261
+ <div className="flex items-center gap-3 mb-3">
262
+ <div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${
263
+ isCurrent ? 'bg-ancient text-void animate-pulse' :
264
+ isCompleted ? 'bg-ancient/20 text-ancient' : 'bg-abyss text-stardust/30'
265
+ }`}>
266
+ {index + 1}
267
+ </div>
268
+ <h3 className={`font-display text-sm tracking-wider ${
269
+ isCurrent ? 'text-ancient' : 'text-ancient/60'
270
+ }`}>{section.title}</h3>
271
+ {isCurrent && !isPlaying && (
272
+ <button
273
+ onClick={() => setIsPlaying(true)}
274
+ className="ml-auto flex items-center gap-1 text-xs text-ancient/60 hover:text-ancient transition-colors"
275
+ >
276
+ <Play className="w-3 h-3" />
277
+ 继续
278
+ </button>
279
+ )}
280
+ </div>
281
+ <div className="ml-11 text-stardust/70 text-sm leading-relaxed space-y-3 bg-abyss/20 rounded-lg p-4">
282
+ {section.content.split('\n\n').map((block: string, i: number) => (
283
+ <div key={i} className="animate-fade-in">
284
+ {isCurrent && !isCompleted ? (
285
+ <TypewriterText
286
+ text={block.replace(/\*\*/g, '')}
287
+ speed={displaySpeed}
288
+ />
289
+ ) : (
290
+ <p className={block.startsWith('**') ? 'text-ancient/80 font-medium' : ''}>
291
+ {block.replace(/\*\*/g, '')}
292
+ </p>
293
+ )}
294
+ </div>
295
+ ))}
296
+ </div>
297
+ </div>
298
+ );
299
+ };
300
+
301
+ if (showLibrary) {
302
+ return (
303
+ <div className="relative min-h-screen pt-20 pb-16 px-4">
304
+ <div className="max-w-6xl mx-auto">
305
+ <button
306
+ onClick={() => setShowLibrary(false)}
307
+ className="inline-flex items-center gap-2 text-stardust/60 hover:text-ancient text-sm mb-6"
308
+ >
309
+ <X className="w-4 h-4" />
310
+ 关闭牌阵库
311
+ </button>
312
+ <SpreadLibrary onSelect={handleSelectSpread} />
313
+ </div>
314
+ </div>
315
+ );
316
+ }
317
+
318
+ return (
319
+ <div className="relative min-h-screen pt-20 pb-16 px-4">
320
+ <div className="max-w-6xl mx-auto">
321
+ <div className="text-center mb-12">
322
+ <h1 className="font-display text-3xl md:text-4xl text-ancient tracking-wider mb-3">
323
+ 塔罗占卜
324
+ </h1>
325
+ <p className="text-stardust/50 text-sm">向宇宙提出你的问题,让牌面揭示答案</p>
326
+ </div>
327
+
328
+ <div className="glass-panel p-6 md:p-8 mb-8">
329
+ <div className="mb-4">
330
+ <label className="block text-ancient/80 text-sm mb-2 tracking-wider">你的问题</label>
331
+ <textarea
332
+ value={question}
333
+ onChange={(e) => setQuestion(e.target.value)}
334
+ placeholder="在心中默念你的问题,然后输入..."
335
+ className="w-full bg-void/50 border border-ancient/20 rounded-lg p-4 text-stardust placeholder-stardust/30 focus:border-ancient/60 focus:outline-none transition-colors resize-none h-24"
336
+ />
337
+ </div>
338
+
339
+ <div className="mb-4">
340
+ <label className="block text-ancient/80 text-sm mb-2 tracking-wider">问题分类</label>
341
+ <div className="flex flex-wrap gap-2">
342
+ {categories.map((cat) => (
343
+ <button
344
+ key={cat.value}
345
+ onClick={() => setCategory(cat.value)}
346
+ className={`px-3 py-1.5 rounded-full text-xs transition-all duration-300 ${
347
+ category === cat.value
348
+ ? 'bg-ancient/20 border border-ancient/50 text-ancient'
349
+ : 'border border-stardust/20 text-stardust/60 hover:border-ancient/30 hover:text-ancient/80'
350
+ }`}
351
+ >
352
+ {cat.label}
353
+ </button>
354
+ ))}
355
+ </div>
356
+ </div>
357
+
358
+ <div className="flex flex-wrap gap-3">
359
+ <button
360
+ onClick={() => setManualMode(!manualMode)}
361
+ className={`flex items-center gap-2 px-4 py-2 rounded-sm text-sm transition-all ${
362
+ manualMode
363
+ ? 'bg-mystic/20 border border-mystic/50 text-mystic'
364
+ : 'border border-stardust/20 text-stardust/60 hover:border-ancient/30'
365
+ }`}
366
+ >
367
+ <Edit3 className="w-4 h-4" />
368
+ {manualMode ? '关闭手动选牌' : '手动选牌'}
369
+ </button>
370
+ <button
371
+ onClick={reset}
372
+ className="flex items-center gap-2 px-4 py-2 rounded-sm border border-stardust/20 text-stardust/60 hover:border-ancient/30 hover:text-ancient/80 transition-all text-sm"
373
+ >
374
+ <RotateCcw className="w-4 h-4" />
375
+ 重置
376
+ </button>
377
+ </div>
378
+ </div>
379
+
380
+ {!showResult && (
381
+ <div className="mb-8">
382
+ <div className="flex items-center justify-between mb-4">
383
+ <h2 className="font-display text-lg text-ancient tracking-wider">选择牌阵</h2>
384
+ <button
385
+ onClick={() => setShowLibrary(true)}
386
+ className="inline-flex items-center gap-2 px-3 py-1.5 border border-ancient/30 text-ancient/80 hover:border-ancient/60 hover:text-ancient text-xs rounded-sm transition-all"
387
+ >
388
+ <Library className="w-3 h-3" />
389
+ 浏览牌阵库
390
+ </button>
391
+ </div>
392
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
393
+ {allSpreads.map((spread) => {
394
+ const isSelected = selectedSpread.id === spread.id;
395
+ return (
396
+ <button
397
+ key={spread.id}
398
+ onClick={() => {
399
+ setSelectedSpread(spread);
400
+ setManualCards([]);
401
+ }}
402
+ className={`relative p-2.5 pt-2 text-left rounded-md border h-24 flex flex-col transition-all duration-200 overflow-hidden ${
403
+ isSelected
404
+ ? 'border-ancient bg-ancient/10 shadow-[0_0_12px_rgba(201,162,39,0.25)]'
405
+ : spread.isCustom
406
+ ? 'border-mystic/30 hover:border-mystic/60 hover:bg-mystic/5'
407
+ : 'border-stardust/15 hover:border-ancient/40 hover:bg-ancient/5'
408
+ }`}
409
+ >
410
+ <span className={`absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0.5 rounded border ${
411
+ isSelected
412
+ ? 'bg-ancient/20 border-ancient/40 text-ancient'
413
+ : 'bg-abyss/60 border-stardust/10 text-stardust/60'
414
+ }`}>
415
+ {spread.positions.length}张
416
+ </span>
417
+ {spread.isCustom && (
418
+ <span className="absolute top-1.5 left-1.5 text-[10px] text-mystic/80 leading-none">自定义</span>
419
+ )}
420
+ <h3 className={`font-display text-xs tracking-wide truncate pr-10 ${spread.isCustom ? 'mt-3.5' : 'mt-0'} ${
421
+ isSelected ? 'text-ancient' : 'text-stardust'
422
+ }`}>
423
+ {spread.name}
424
+ </h3>
425
+ <p className={`mt-auto text-[11px] leading-tight line-clamp-1 ${
426
+ isSelected ? 'text-ancient/60' : 'text-stardust/40'
427
+ }`}>
428
+ {spread.description}
429
+ </p>
430
+ </button>
431
+ );
432
+ })}
433
+ </div>
434
+ </div>
435
+ )}
436
+
437
+ {manualMode && !showResult && (
438
+ <div className="glass-panel p-6 mb-8">
439
+ <div className="flex flex-wrap items-center justify-between gap-4 mb-4">
440
+ <h3 className="font-display text-ancient text-sm tracking-wider">
441
+ 手动选牌 ({manualCards.length}/{selectedSpread.positions.length})
442
+ </h3>
443
+ <button
444
+ onClick={() => {
445
+ if (manualCards.length === selectedSpread.positions.length) {
446
+ handleInterpret('manual');
447
+ }
448
+ }}
449
+ disabled={manualCards.length !== selectedSpread.positions.length}
450
+ className="px-4 py-1.5 bg-ancient/20 border border-ancient/50 text-ancient hover:bg-ancient/30 transition-all rounded-sm text-sm disabled:opacity-30"
451
+ >
452
+ {manualCards.length === selectedSpread.positions.length ? '确认并解读' : `选满${selectedSpread.positions.length}张`}
453
+ </button>
454
+ </div>
455
+
456
+ {manualCards.length > 0 && (
457
+ <div className="mb-4 p-3 bg-abyss/30 rounded border border-ancient/20">
458
+ <div className="text-xs text-ancient/60 mb-2">已选牌位(点击切换正逆位,点击×删除)</div>
459
+ <div className="flex flex-wrap gap-2">
460
+ {manualCards.map((mc, idx) => {
461
+ const card = tarotCards.find(c => c.id === mc.cardId);
462
+ const pos = selectedSpread.positions[idx];
463
+ return (
464
+ <div key={idx} className="flex items-center gap-2 px-2 py-1 bg-abyss border border-ancient/30 rounded">
465
+ <span className="text-xs text-ancient/60">{pos?.name || `#${idx+1}`}:</span>
466
+ <button
467
+ onClick={() => toggleManualReversed(idx)}
468
+ className={`text-xs ${mc.isReversed ? 'text-crimson' : 'text-stardust'}`}
469
+ >
470
+ {card?.name}{mc.isReversed ? '(逆)' : ''}
471
+ </button>
472
+ <button onClick={() => removeManualCard(idx)} className="text-stardust/40 hover:text-crimson">
473
+ <X className="w-3 h-3" />
474
+ </button>
475
+ </div>
476
+ );
477
+ })}
478
+ </div>
479
+ </div>
480
+ )}
481
+
482
+ <div className="flex flex-col sm:flex-row gap-2 mb-4">
483
+ <div className="relative flex-1">
484
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-stardust/40" />
485
+ <input
486
+ type="text"
487
+ value={cardSearch}
488
+ onChange={(e) => setCardSearch(e.target.value)}
489
+ placeholder="搜索牌名..."
490
+ className="w-full bg-void/50 border border-ancient/20 rounded pl-10 pr-3 py-2 text-sm text-stardust focus:border-ancient/60 focus:outline-none"
491
+ />
492
+ </div>
493
+ </div>
494
+ <div className="flex flex-wrap gap-2 mb-4">
495
+ {SUIT_TABS.map((tab) => (
496
+ <button
497
+ key={tab.value}
498
+ onClick={() => setActiveSuit(tab.value)}
499
+ className={`px-3 py-1 rounded-full text-xs transition-all ${
500
+ activeSuit === tab.value
501
+ ? 'bg-ancient/20 border border-ancient/50 text-ancient'
502
+ : 'border border-stardust/20 text-stardust/60 hover:border-ancient/30'
503
+ }`}
504
+ >
505
+ {tab.label}
506
+ </button>
507
+ ))}
508
+ </div>
509
+
510
+ <div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2 max-h-96 overflow-y-auto">
511
+ {filteredCards.map((card) => (
512
+ <button
513
+ key={card.id}
514
+ onClick={() => toggleManualCard(card.id)}
515
+ className={`p-2 rounded border text-xs transition-all hover:border-ancient/50 ${
516
+ manualCards.some(mc => mc.cardId === card.id)
517
+ ? 'border-ancient bg-ancient/20 text-ancient'
518
+ : 'border-stardust/10 text-stardust/50'
519
+ }`}
520
+ >
521
+ <div className="text-center">
522
+ <div className="text-lg mb-1">
523
+ {card.arcana === 'major' ? '☽' : card.suit === 'wands' ? '⚡' : card.suit === 'cups' ? '🏆' : card.suit === 'swords' ? '⚔️' : '💰'}
524
+ </div>
525
+ <div className="text-[10px] truncate">{card.name}</div>
526
+ </div>
527
+ </button>
528
+ ))}
529
+ </div>
530
+ </div>
531
+ )}
532
+
533
+ {!manualMode && !showResult && (
534
+ <div className="text-center mb-12">
535
+ <button
536
+ onClick={() => handleInterpret('random')}
537
+ disabled={!question.trim() || isDrawing}
538
+ className="inline-flex items-center gap-3 px-10 py-4 bg-ancient/20 border border-ancient/60 text-ancient hover:bg-ancient/30 hover:border-ancient transition-all duration-500 rounded-sm disabled:opacity-30 disabled:cursor-not-allowed tracking-wider"
539
+ >
540
+ {isDrawing ? (
541
+ <>
542
+ <Shuffle className="w-5 h-5 animate-spin" />
543
+ 抽牌中...
544
+ </>
545
+ ) : isInterpreting ? (
546
+ <>
547
+ <Sparkles className="w-5 h-5 animate-pulse" />
548
+ 解读中...
549
+ </>
550
+ ) : (
551
+ <>
552
+ <Sparkles className="w-5 h-5" />
553
+ 抽牌并解读
554
+ </>
555
+ )}
556
+ </button>
557
+ </div>
558
+ )}
559
+
560
+ {drawnCards.length > 0 && (
561
+ <div className="mb-12">
562
+ <h2 className="font-display text-lg text-ancient tracking-wider mb-6 text-center">
563
+ {selectedSpread.name}
564
+ </h2>
565
+ <div className="flex flex-wrap justify-center gap-4 md:gap-6">
566
+ {drawnCards.map((drawn, index) => {
567
+ const card = tarotCards.find((c) => c.id === drawn.cardId);
568
+ if (!card) return null;
569
+ return (
570
+ <div key={index} className="flex flex-col items-center">
571
+ <TarotCard
572
+ card={card}
573
+ isReversed={drawn.isReversed}
574
+ isFlipped={showResult}
575
+ size="md"
576
+ />
577
+ <span className="mt-3 text-xs text-stardust/50 text-center max-w-24">
578
+ {selectedSpread.positions.find((p) => p.id === drawn.positionId)?.name}
579
+ </span>
580
+ </div>
581
+ );
582
+ })}
583
+ </div>
584
+ </div>
585
+ )}
586
+
587
+ {isInterpreting && !showResult && (
588
+ <div className="glass-panel p-12 mb-8 text-center">
589
+ <div className="inline-block animate-pulse">
590
+ <Sparkles className="w-12 h-12 text-ancient/60 mx-auto mb-4" />
591
+ </div>
592
+ <p className="text-ancient/70 text-sm">正在解析问题能量...</p>
593
+ <p className="text-stardust/40 text-xs mt-2">基于语义分析与牌面信息的整合解读</p>
594
+ </div>
595
+ )}
596
+
597
+ {showResult && interpretation && (
598
+ <div className="glass-panel p-6 md:p-8 mb-8">
599
+ <div className="flex items-center justify-between mb-6 flex-wrap gap-2">
600
+ <h2 className="font-display text-xl text-ancient tracking-wider">解读结果</h2>
601
+ <label className="flex items-center gap-2 text-sm text-stardust/60 cursor-pointer">
602
+ <input
603
+ type="checkbox"
604
+ checked={isComboEnabled}
605
+ onChange={(e) => setIsComboEnabled(e.target.checked)}
606
+ className="rounded border-ancient/30 bg-void text-ancient focus:ring-ancient"
607
+ />
608
+ 开启组合占卜
609
+ </label>
610
+ </div>
611
+
612
+ {interpretation.semantic && (
613
+ <div className="mb-6 p-4 bg-ancient/5 border border-ancient/20 rounded-lg">
614
+ <div className="text-xs text-ancient/60 mb-3">本次解读识别到:</div>
615
+ <div className="flex flex-wrap gap-2">
616
+ <span className="px-3 py-1 rounded-full bg-ancient/10 border border-ancient/20 text-ancient text-xs">
617
+ 场景:{interpretation.semantic.sceneLabel}
618
+ </span>
619
+ <span className="px-3 py-1 rounded-full bg-mystic/10 border border-mystic/20 text-mystic text-xs">
620
+ 情绪:{interpretation.semantic.emotionLabel}
621
+ </span>
622
+ <span className="px-3 py-1 rounded-full bg-abyss border border-stardust/20 text-stardust/60 text-xs">
623
+ 时态:{interpretation.semantic.tenseLabel}
624
+ </span>
625
+ <span className="px-3 py-1 rounded-full bg-abyss border border-stardust/20 text-stardust/60 text-xs">
626
+ 对象:{interpretation.semantic.subjectLabel}
627
+ </span>
628
+ </div>
629
+ </div>
630
+ )}
631
+
632
+ <div className="bg-abyss/30 rounded-lg p-4 mb-6">
633
+ <div className="flex items-center justify-between">
634
+ <div className="flex items-center gap-4">
635
+ <button
636
+ onClick={() => setIsPlaying(!isPlaying)}
637
+ className="p-2 rounded-full bg-ancient/20 text-ancient hover:bg-ancient/30 transition-colors"
638
+ >
639
+ {isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
640
+ </button>
641
+ <button
642
+ onClick={() => setCurrentSectionIndex(prev => Math.max(0, prev - 1))}
643
+ className="p-2 rounded-full bg-abyss border border-stardust/20 text-stardust/60 hover:border-ancient/30 hover:text-ancient transition-colors"
644
+ >
645
+ <SkipBack className="w-4 h-4" />
646
+ </button>
647
+ <button
648
+ onClick={() => setCurrentSectionIndex(prev => Math.min(interpretation.sections.length - 1, prev + 1))}
649
+ className="p-2 rounded-full bg-abyss border border-stardust/20 text-stardust/60 hover:border-ancient/30 hover:text-ancient transition-colors"
650
+ >
651
+ <SkipForward className="w-4 h-4" />
652
+ </button>
653
+ </div>
654
+ <div className="flex items-center gap-3">
655
+ <span className="text-xs text-stardust/40">显示速度</span>
656
+ <input
657
+ type="range"
658
+ min="30"
659
+ max="100"
660
+ value={displaySpeed}
661
+ onChange={(e) => setDisplaySpeed(Number(e.target.value))}
662
+ className="w-24 accent-ancient"
663
+ />
664
+ </div>
665
+ </div>
666
+ <div className="mt-4 flex items-center gap-2">
667
+ {interpretation.sections.map((_, idx) => (
668
+ <button
669
+ key={idx}
670
+ onClick={() => goToSection(idx)}
671
+ className={`flex-1 h-1.5 rounded-full transition-all ${
672
+ idx === currentSectionIndex ? 'bg-ancient' :
673
+ idx < currentSectionIndex ? 'bg-ancient/40' : 'bg-abyss'
674
+ }`}
675
+ />
676
+ ))}
677
+ </div>
678
+ </div>
679
+
680
+ <div className="space-y-1">
681
+ {interpretation.sections.map((s: any, idx: number) => renderInterpretationSection(s, idx))}
682
+ </div>
683
+
684
+ {isComboEnabled && (
685
+ <div className="mt-8 pt-6 border-t border-ancient/20">
686
+ <div className="flex flex-wrap items-center gap-4 mb-4">
687
+ <h3 className="font-display text-ancient text-sm tracking-wider">东方术数</h3>
688
+ <select
689
+ value={selectedComboMethod}
690
+ onChange={(e) => setSelectedComboMethod(e.target.value)}
691
+ className="bg-void/50 border border-ancient/20 rounded px-3 py-1 text-sm text-stardust focus:border-ancient/60 focus:outline-none"
692
+ >
693
+ {comboMethods.map((method) => (
694
+ <option key={method.value} value={method.value}>{method.label}</option>
695
+ ))}
696
+ </select>
697
+ <button
698
+ onClick={handleCombo}
699
+ className="px-4 py-1 bg-mystic/20 border border-mystic/50 text-mystic hover:bg-mystic/30 transition-all rounded-sm text-sm"
700
+ >
701
+ 起卦
702
+ </button>
703
+ </div>
704
+
705
+ {comboResultLocal && (
706
+ <div className="bg-abyss/40 rounded-lg p-4 border border-mystic/20">
707
+ <div className="flex items-center gap-2 mb-2">
708
+ <span className="text-mystic font-display text-sm">{comboResultLocal.methodName}</span>
709
+ <span className="text-ancient text-sm">{comboResultLocal.result}</span>
710
+ </div>
711
+ <p className="text-stardust/60 text-sm mb-2">{comboResultLocal.detail}</p>
712
+ <p className="text-mystic/70 text-sm">{comboResultLocal.relationToTarot}</p>
713
+ </div>
714
+ )}
715
+ </div>
716
+ )}
717
+
718
+ <div className="flex flex-wrap gap-3 mt-8 pt-6 border-t border-ancient/10">
719
+ <button
720
+ onClick={reset}
721
+ className="flex items-center gap-2 px-6 py-2 border border-ancient/40 text-ancient hover:bg-ancient/10 transition-all rounded-sm text-sm"
722
+ >
723
+ <RotateCcw className="w-4 h-4" />
724
+ 再次占卜
725
+ </button>
726
+ <button
727
+ onClick={() => navigate('/history')}
728
+ className="flex items-center gap-2 px-6 py-2 border border-stardust/20 text-stardust/60 hover:border-stardust/40 hover:text-stardust transition-all rounded-sm text-sm"
729
+ >
730
+ 查看历史
731
+ </button>
732
+ </div>
733
+ </div>
734
+ )}
735
+ </div>
736
+ </div>
737
+ );
738
+ }