@lmy54321/design-system 1.1.3 → 1.2.0

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.
@@ -0,0 +1,954 @@
1
+ import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+ import { cn } from "@lmy54321/design-system";
4
+ import { SearchBox } from "@lmy54321/design-system";
5
+ import { Tag } from "@lmy54321/design-system";
6
+ import { Btn } from "@lmy54321/design-system";
7
+ import { Toast } from "@lmy54321/design-system";
8
+ import { IconFont } from "@lmy54321/design-system";
9
+ import { BottomSheet } from "@lmy54321/design-system";
10
+ import { ImageWithFallback } from "@lmy54321/design-system";
11
+
12
+ /* ══════════════════════════════════════════
13
+ 类型定义
14
+ ══════════════════════════════════════════ */
15
+
16
+ type FlowStep = "home" | "sug" | "result" | "detail";
17
+
18
+ interface SugItem {
19
+ id: string;
20
+ name: string;
21
+ address: string;
22
+ category: string;
23
+ distance: string;
24
+ }
25
+
26
+ interface POIDetail {
27
+ id: string;
28
+ name: string;
29
+ address: string;
30
+ category: string;
31
+ distance: string;
32
+ rating: string;
33
+ ratingCount: string;
34
+ pricePerPerson: string;
35
+ phone: string;
36
+ hours: string;
37
+ image: string;
38
+ tags: string[];
39
+ photos: string[];
40
+ lat: number;
41
+ lng: number;
42
+ }
43
+
44
+ /* ══════════════════════════════════════════
45
+ 模拟数据
46
+ ══════════════════════════════════════════ */
47
+
48
+ const searchHistory = [
49
+ "天安门广场",
50
+ "三里屯太古里",
51
+ "北京南站",
52
+ "颐和园",
53
+ "望京SOHO",
54
+ "朝阳大悦城",
55
+ ];
56
+
57
+ const hotSearches = [
58
+ { rank: 1, text: "故宫博物院", hot: true },
59
+ { rank: 2, text: "环球影城", hot: true },
60
+ { rank: 3, text: "国贸大厦", hot: false },
61
+ { rank: 4, text: "西单大悦城", hot: false },
62
+ { rank: 5, text: "鸟巢体育馆", hot: false },
63
+ { rank: 6, text: "中关村", hot: false },
64
+ ];
65
+
66
+ const quickCategories = [
67
+ { icon: "fork", label: "美食" },
68
+ { icon: "tea", label: "咖啡" },
69
+ { icon: "building", label: "酒店" },
70
+ { icon: "vehicle", label: "加油站" },
71
+ { icon: "map-marked", label: "停车场" },
72
+ { icon: "film", label: "电影" },
73
+ ];
74
+
75
+ const allSuggestions: SugItem[] = [
76
+ { id: "1", name: "天安门广场", address: "北京市东城区东长安街", category: "景点", distance: "6.2km" },
77
+ { id: "2", name: "天安门城楼", address: "北京市东城区天安门", category: "景点", distance: "6.3km" },
78
+ { id: "3", name: "天坛公园", address: "北京市东城区天坛内东里7号", category: "公园", distance: "8.1km" },
79
+ { id: "4", name: "天通苑", address: "北京市昌平区天通苑社区", category: "住宅区", distance: "18.5km" },
80
+ { id: "5", name: "三里屯太古里", address: "北京市朝阳区三里屯路19号", category: "商场", distance: "4.5km" },
81
+ { id: "6", name: "三里屯SOHO", address: "北京市朝阳区工体北路8号", category: "写字楼", distance: "4.2km" },
82
+ { id: "7", name: "故宫博物院", address: "北京市东城区景山前街4号", category: "博物馆", distance: "7.0km" },
83
+ { id: "8", name: "颐和园", address: "北京市海淀区新建宫门路19号", category: "公园", distance: "14.2km" },
84
+ { id: "9", name: "北京南站", address: "北京市丰台区永外大街车站路12号", category: "火车站", distance: "8.5km" },
85
+ { id: "10", name: "北京西站", address: "北京市丰台区莲花池东路118号", category: "火车站", distance: "10.3km" },
86
+ { id: "11", name: "望京SOHO", address: "北京市朝阳区望京街10号", category: "写字楼", distance: "2.1km" },
87
+ { id: "12", name: "国贸商城", address: "北京市朝阳区建国门外大街1号", category: "商场", distance: "5.8km" },
88
+ { id: "13", name: "朝阳大悦城", address: "北京市朝阳区朝阳北路101号", category: "商场", distance: "3.2km" },
89
+ { id: "14", name: "中关村软件园", address: "北京市海淀区东北旺西路8号", category: "科技园", distance: "15.2km" },
90
+ { id: "15", name: "环球影城", address: "北京市通州区京哈高速与东六环路交叉口", category: "主题公园", distance: "25.0km" },
91
+ { id: "16", name: "西单大悦城", address: "北京市西城区西单北大街131号", category: "商场", distance: "8.8km" },
92
+ ];
93
+
94
+ /* 搜索结果列表 POI 数据 — 更丰富 */
95
+ const searchResultPOIs: POIDetail[] = [
96
+ // ── 美食 ──
97
+ {
98
+ id: "r1", name: "四季民福烤鸭店(故宫店)", address: "北京市东城区南池子大街11号", category: "美食",
99
+ distance: "5.8km", rating: "4.7", ratingCount: "3.2万", pricePerPerson: "168",
100
+ phone: "010-65260008", hours: "11:00-22:00", lat: 39.9120, lng: 116.4050,
101
+ tags: ["烤鸭", "老字号", "排队名店"],
102
+ image: "/images/scene/peking-duck.png",
103
+ photos: [
104
+ "/images/scene/peking-duck.png",
105
+ "/images/scene/hotpot.png",
106
+ "/images/scene/pizza.png",
107
+ ],
108
+ },
109
+ {
110
+ id: "r2", name: "海底捞火锅(望京店)", address: "北京市朝阳区望京街9号", category: "美食",
111
+ distance: "2.3km", rating: "4.6", ratingCount: "1.8万", pricePerPerson: "135",
112
+ phone: "010-64788899", hours: "10:00-次日07:00", lat: 39.9920, lng: 116.4800,
113
+ tags: ["火锅", "服务好", "24小时"],
114
+ image: "/images/scene/hotpot.png",
115
+ photos: [
116
+ "/images/scene/hotpot.png",
117
+ "/images/scene/peking-duck.png",
118
+ ],
119
+ },
120
+ {
121
+ id: "r3", name: "文和友老长沙龙虾馆", address: "北京市朝阳区三里屯路19号", category: "美食",
122
+ distance: "4.5km", rating: "4.4", ratingCount: "8765", pricePerPerson: "108",
123
+ phone: "010-64178800", hours: "11:30-凌晨02:00", lat: 39.9340, lng: 116.4540,
124
+ tags: ["小龙虾", "网红餐厅", "夜宵"],
125
+ image: "/images/scene/crayfish.png",
126
+ photos: [
127
+ "/images/scene/crayfish.png",
128
+ "/images/scene/hotpot.png",
129
+ ],
130
+ },
131
+ // ── 咖啡 ──
132
+ {
133
+ id: "r4", name: "Manner Coffee(国贸店)", address: "北京市朝阳区建国门外大街1号", category: "咖啡",
134
+ distance: "5.2km", rating: "4.5", ratingCount: "6543", pricePerPerson: "25",
135
+ phone: "010-65051234", hours: "07:30-20:00", lat: 39.9085, lng: 116.4600,
136
+ tags: ["精品咖啡", "性价比", "自带杯减5"],
137
+ image: "/images/scene/pour-coffee.png",
138
+ photos: [
139
+ "/images/scene/pour-coffee.png",
140
+ "/images/scene/cafe.png",
141
+ "/images/scene/matcha.png",
142
+ ],
143
+ },
144
+ {
145
+ id: "r5", name: "星巴克臻选(三里屯旗舰店)", address: "北京市朝阳区三里屯路11号", category: "咖啡",
146
+ distance: "4.6km", rating: "4.3", ratingCount: "1.2万", pricePerPerson: "42",
147
+ phone: "010-64176688", hours: "07:00-23:00", lat: 39.9350, lng: 116.4530,
148
+ tags: ["臻选门店", "手冲咖啡", "空间大"],
149
+ image: "/images/scene/cafe.png",
150
+ photos: [
151
+ "/images/scene/cafe.png",
152
+ "/images/scene/pour-coffee.png",
153
+ ],
154
+ },
155
+ // ── 酒店 ──
156
+ {
157
+ id: "r6", name: "北京国贸大酒店", address: "北京市朝阳区建国门外大街1号", category: "酒店",
158
+ distance: "5.3km", rating: "4.8", ratingCount: "2.1万", pricePerPerson: "1280",
159
+ phone: "010-65052299", hours: "全天", lat: 39.9080, lng: 116.4610,
160
+ tags: ["五星级", "商务出行", "CBD核心"],
161
+ image: "/images/scene/hotel.png",
162
+ photos: [
163
+ "/images/scene/hotel.png",
164
+ "/images/scene/hotel-room.png",
165
+ ],
166
+ },
167
+ {
168
+ id: "r7", name: "亚朵酒店(望京SOHO店)", address: "北京市朝阳区望京街10号", category: "酒店",
169
+ distance: "2.0km", rating: "4.6", ratingCount: "9876", pricePerPerson: "458",
170
+ phone: "010-64789900", hours: "全天", lat: 39.9930, lng: 116.4790,
171
+ tags: ["人文主题", "免费书吧", "性价比"],
172
+ image: "/images/scene/hotel-room.png",
173
+ photos: [
174
+ "/images/scene/hotel-room.png",
175
+ "/images/scene/hotel.png",
176
+ ],
177
+ },
178
+ // ── 加油站 ──
179
+ {
180
+ id: "r8", name: "中国石化(望京南加油站)", address: "北京市朝阳区广顺南大街16号", category: "加油站",
181
+ distance: "1.8km", rating: "4.2", ratingCount: "3245", pricePerPerson: "",
182
+ phone: "010-64739988", hours: "06:00-23:00", lat: 39.9870, lng: 116.4720,
183
+ tags: ["92号", "95号", "柴油"],
184
+ image: "/images/scene/gas-station.png",
185
+ photos: [
186
+ "/images/scene/gas-station.png",
187
+ ],
188
+ },
189
+ {
190
+ id: "r9", name: "中国石油(酒仙桥加油站)", address: "北京市朝阳区酒仙桥路12号", category: "加油站",
191
+ distance: "3.1km", rating: "4.0", ratingCount: "2180", pricePerPerson: "",
192
+ phone: "010-64371122", hours: "全天", lat: 39.9750, lng: 116.4900,
193
+ tags: ["24小时", "洗车服务", "便利店"],
194
+ image: "/images/scene/gas-station.png",
195
+ photos: [
196
+ "/images/scene/gas-station.png",
197
+ ],
198
+ },
199
+ // ── 停车场 ──
200
+ {
201
+ id: "r10", name: "望京SOHO地下停车场", address: "北京市朝阳区望京街10号B2", category: "停车场",
202
+ distance: "2.1km", rating: "4.1", ratingCount: "5432", pricePerPerson: "8",
203
+ phone: "010-64780088", hours: "全天", lat: 39.9925, lng: 116.4785,
204
+ tags: ["地下停车", "8元/小时", "车位充足"],
205
+ image: "/images/scene/parking.png",
206
+ photos: [
207
+ "/images/scene/parking.png",
208
+ ],
209
+ },
210
+ {
211
+ id: "r11", name: "三里屯太古里停车场", address: "北京市朝阳区三里屯路19号B1", category: "停车场",
212
+ distance: "4.5km", rating: "3.9", ratingCount: "8765", pricePerPerson: "10",
213
+ phone: "010-64170088", hours: "全天", lat: 39.9345, lng: 116.4535,
214
+ tags: ["消费满减", "10元/小时", "节假日拥挤"],
215
+ image: "/images/scene/parking.png",
216
+ photos: [
217
+ "/images/scene/parking.png",
218
+ ],
219
+ },
220
+ // ── 电影 ──
221
+ {
222
+ id: "r12", name: "万达影城(望京IMAX店)", address: "北京市朝阳区望京西路48号", category: "电影",
223
+ distance: "2.5km", rating: "4.5", ratingCount: "2.3万", pricePerPerson: "55",
224
+ phone: "010-64738866", hours: "09:00-次日01:00", lat: 39.9880, lng: 116.4680,
225
+ tags: ["IMAX", "杜比全景声", "VIP厅"],
226
+ image: "/images/scene/cinema.png",
227
+ photos: [
228
+ "/images/scene/cinema.png",
229
+ ],
230
+ },
231
+ {
232
+ id: "r13", name: "CGV影城(三里屯店)", address: "北京市朝阳区三里屯路19号太古里南区", category: "电影",
233
+ distance: "4.7km", rating: "4.6", ratingCount: "1.5万", pricePerPerson: "68",
234
+ phone: "010-64176677", hours: "10:00-次日00:00", lat: 39.9348, lng: 116.4528,
235
+ tags: ["4DX", "ScreenX", "情侣座"],
236
+ image: "/images/scene/cinema.png",
237
+ photos: [
238
+ "/images/scene/cinema.png",
239
+ ],
240
+ },
241
+ // ── 景点(保留原有) ──
242
+ {
243
+ id: "r14", name: "故宫博物院", address: "北京市东城区景山前街4号", category: "景点",
244
+ distance: "7.0km", rating: "4.9", ratingCount: "28.6万", pricePerPerson: "60",
245
+ phone: "010-85007421", hours: "08:30-17:00", lat: 39.9163, lng: 116.3972,
246
+ tags: ["5A景区", "世界文化遗产", "必去打卡"],
247
+ image: "/images/feed-axis.png",
248
+ photos: [
249
+ "/images/feed-axis.png",
250
+ "/images/feed/feed-museum.png",
251
+ "/images/scene/trip-palace.png",
252
+ "/images/scene/jingshan.png",
253
+ ],
254
+ },
255
+ {
256
+ id: "r15", name: "景山公园", address: "北京市西城区景山西街44号", category: "景点",
257
+ distance: "6.8km", rating: "4.8", ratingCount: "15.3万", pricePerPerson: "2",
258
+ phone: "010-64038098", hours: "06:00-21:00", lat: 39.9245, lng: 116.3967,
259
+ tags: ["登高望远", "故宫全景", "历史遗迹"],
260
+ image: "/images/scene/jingshan.png",
261
+ photos: [
262
+ "/images/scene/jingshan.png",
263
+ "/images/scene/trip-palace.png",
264
+ ],
265
+ },
266
+ ];
267
+
268
+ /* ══════════════════════════════════════════
269
+ 工具函数
270
+ ══════════════════════════════════════════ */
271
+
272
+ function HighlightText({ text, query }: { text: string; query: string }) {
273
+ if (!query) return <>{text}</>;
274
+ const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"));
275
+ return (
276
+ <>
277
+ {parts.map((part, i) =>
278
+ part.toLowerCase() === query.toLowerCase() ? (
279
+ <span key={i} className="text-accent font-medium">{part}</span>
280
+ ) : (
281
+ <span key={i}>{part}</span>
282
+ )
283
+ )}
284
+ </>
285
+ );
286
+ }
287
+
288
+ /* ══════════════════════════════════════════
289
+ Step 1: 搜索首页
290
+ ══════════════════════════════════════════ */
291
+
292
+ function SearchHome({
293
+ history,
294
+ onSelectHistory,
295
+ onRemoveHistory,
296
+ onClearHistory,
297
+ onSelectCategory,
298
+ onSelectHot,
299
+ }: {
300
+ history: string[];
301
+ onSelectHistory: (t: string) => void;
302
+ onRemoveHistory: (t: string) => void;
303
+ onClearHistory: () => void;
304
+ onSelectCategory: (label: string) => void;
305
+ onSelectHot: (t: string) => void;
306
+ }) {
307
+ return (
308
+ <motion.div
309
+ key="search-home"
310
+ initial={{ opacity: 0, y: 12 }}
311
+ animate={{ opacity: 1, y: 0 }}
312
+ exit={{ opacity: 0, y: -8 }}
313
+ transition={{ duration: 0.2 }}
314
+ >
315
+ {/* 快捷分类 */}
316
+ <div className="px-[var(--spacing-base)] pt-[var(--spacing-xs)] pb-[var(--spacing-sm)]">
317
+ <div className="flex items-center gap-[6px] mb-[var(--spacing-sm)] px-[var(--spacing-xxs)]">
318
+ <IconFont name="star" variant="filled" size="13px" className="text-accent" />
319
+ <span className="text-[13px] font-medium text-foreground/70 leading-[18px]">快捷探索</span>
320
+ </div>
321
+ <div className="flex gap-[var(--spacing-md)] overflow-x-auto pb-[var(--spacing-xs)]" style={{ scrollbarWidth: "none" }}>
322
+ {quickCategories.map((cat, index) => (
323
+ <motion.button
324
+ key={cat.label}
325
+ initial={{ opacity: 0, y: 10 }}
326
+ animate={{ opacity: 1, y: 0 }}
327
+ transition={{ delay: index * 0.04, duration: 0.2 }}
328
+ onClick={() => onSelectCategory(cat.label)}
329
+ className="flex flex-col items-center shrink-0 cursor-pointer active:scale-95 transition-transform"
330
+ >
331
+ <div className="size-[48px] rounded-full bg-card-muted flex items-center justify-center mb-[6px]">
332
+ <IconFont name={cat.icon} size="22px" className="text-foreground/70" />
333
+ </div>
334
+ <span className="text-[12px] text-foreground font-medium leading-[16px] whitespace-nowrap">{cat.label}</span>
335
+ </motion.button>
336
+ ))}
337
+ </div>
338
+ </div>
339
+
340
+ {/* 搜索历史 */}
341
+ {history.length > 0 && (
342
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-md)]">
343
+ <div className="flex items-center justify-between mb-[var(--spacing-sm)]">
344
+ <div className="flex items-center gap-[var(--spacing-xxs)]">
345
+ <IconFont name="time" size="14px" className="text-muted-foreground" />
346
+ <span className="text-[13px] font-medium text-foreground leading-[18px]">最近搜索</span>
347
+ </div>
348
+ <button onClick={onClearHistory} className="text-[12px] text-muted-foreground active:opacity-60 transition-opacity">清空</button>
349
+ </div>
350
+ <div className="flex flex-wrap gap-[var(--spacing-xs)] items-center">
351
+ {history.map((text, index) => (
352
+ <motion.button
353
+ key={text}
354
+ initial={{ opacity: 0, scale: 0.92 }}
355
+ animate={{ opacity: 1, scale: 1 }}
356
+ transition={{ delay: index * 0.03, duration: 0.2 }}
357
+ onClick={() => onSelectHistory(text)}
358
+ className="h-[34px] px-[var(--spacing-sm)] flex items-center gap-[6px] bg-card rounded-[var(--radius-button)] border border-card-border shadow-[var(--elevation-sm)] text-[13px] text-foreground active:bg-muted transition-colors"
359
+ >
360
+ {text}
361
+ <span
362
+ onClick={(e) => { e.stopPropagation(); onRemoveHistory(text); }}
363
+ className="text-muted-foreground/40 hover:text-muted-foreground"
364
+ >
365
+ <IconFont name="close" size="11px" />
366
+ </span>
367
+ </motion.button>
368
+ ))}
369
+ </div>
370
+ </div>
371
+ )}
372
+
373
+ <div className="mx-[var(--spacing-lg)] h-px bg-border/30" />
374
+
375
+ {/* 热门搜索 */}
376
+ <div className="px-[var(--spacing-base)] pt-[var(--spacing-md)] pb-[var(--spacing-lg)]">
377
+ <div className="flex items-center gap-[var(--spacing-xxs)] mb-[var(--spacing-sm)]">
378
+ <IconFont name="trending-up" size="14px" className="text-destructive" />
379
+ <span className="text-[13px] font-medium text-foreground leading-[18px]">热门搜索</span>
380
+ </div>
381
+ <div className="rounded-[var(--radius)] bg-card-muted p-[var(--spacing-sm)]">
382
+ <div className="flex gap-[var(--spacing-xs)]">
383
+ {[0, 1].map(col => (
384
+ <div key={col} className="flex-1 flex flex-col">
385
+ {hotSearches.filter((_, i) => i % 2 === col).map(item => (
386
+ <motion.button
387
+ key={item.text}
388
+ initial={{ opacity: 0, y: 10 }}
389
+ animate={{ opacity: 1, y: 0 }}
390
+ transition={{ delay: item.rank * 0.04, duration: 0.25 }}
391
+ onClick={() => onSelectHot(item.text)}
392
+ className="flex items-center gap-[var(--spacing-xs)] px-[var(--spacing-xs)] py-[var(--spacing-sm)] text-left active:bg-background/60 rounded-[12px] transition-colors"
393
+ >
394
+ <div className={cn("size-[22px] rounded-full flex items-center justify-center shrink-0 text-[11px] font-bold", item.rank <= 3 ? "bg-destructive/12 text-destructive" : "bg-background text-muted-foreground")}>
395
+ {item.rank}
396
+ </div>
397
+ <span className="text-[14px] text-foreground leading-[20px] flex-1 truncate font-medium">{item.text}</span>
398
+ {item.hot && <Tag label="热" size="xs" className="bg-destructive/10 text-destructive border-transparent shrink-0" />}
399
+ </motion.button>
400
+ ))}
401
+ </div>
402
+ ))}
403
+ </div>
404
+ </div>
405
+ </div>
406
+ </motion.div>
407
+ );
408
+ }
409
+
410
+ /* ══════════════════════════════════════════
411
+ Step 2: Sug 搜索建议
412
+ ══════════════════════════════════════════ */
413
+
414
+ function SugList({
415
+ suggestions,
416
+ query,
417
+ onSelect,
418
+ onFill,
419
+ }: {
420
+ suggestions: SugItem[];
421
+ query: string;
422
+ onSelect: (item: SugItem) => void;
423
+ onFill: (name: string) => void;
424
+ }) {
425
+ if (suggestions.length === 0) {
426
+ return (
427
+ <motion.div key="sug-empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center justify-center py-[60px]">
428
+ <div className="size-[56px] rounded-full bg-card-muted flex items-center justify-center text-muted-foreground mb-[var(--spacing-sm)]">
429
+ <IconFont name="search" size="24px" />
430
+ </div>
431
+ <p className="text-[14px] text-muted-foreground">未找到「{query}」相关结果</p>
432
+ <p className="text-[12px] text-muted-foreground/60 mt-[var(--spacing-xxs)]">试试其他关键词</p>
433
+ </motion.div>
434
+ );
435
+ }
436
+
437
+ return (
438
+ <motion.div
439
+ key="sug-list"
440
+ initial={{ opacity: 0, y: 12 }}
441
+ animate={{ opacity: 1, y: 0 }}
442
+ exit={{ opacity: 0, y: -8 }}
443
+ transition={{ duration: 0.2 }}
444
+ className="flex flex-col px-[var(--spacing-base)]"
445
+ >
446
+ {suggestions.map((item, index) => (
447
+ <motion.button
448
+ key={item.id}
449
+ initial={{ opacity: 0, x: -10 }}
450
+ animate={{ opacity: 1, x: 0 }}
451
+ transition={{ delay: index * 0.03 }}
452
+ onClick={() => onSelect(item)}
453
+ className={cn(
454
+ "flex items-center gap-[var(--spacing-sm)] py-[var(--spacing-sm)] text-left",
455
+ "active:bg-muted/40 -mx-[var(--spacing-xxs)] px-[var(--spacing-xxs)] rounded-[12px] transition-colors",
456
+ index < suggestions.length - 1 && "border-b border-border/50"
457
+ )}
458
+ >
459
+ <div className="size-[36px] rounded-full bg-card-muted flex items-center justify-center text-muted-foreground shrink-0">
460
+ <IconFont name="location" size="18px" />
461
+ </div>
462
+ <div className="flex-1 min-w-0">
463
+ <div className="flex items-center gap-[var(--spacing-xs)]">
464
+ <span className="text-[15px] font-medium text-foreground leading-[22px] truncate">
465
+ <HighlightText text={item.name} query={query} />
466
+ </span>
467
+ <Tag label={item.category} size="xs" className="bg-card-muted text-muted-foreground border-transparent shrink-0" />
468
+ </div>
469
+ <span className="text-[12px] text-muted-foreground leading-[16px] truncate block mt-[2px]">
470
+ <HighlightText text={item.address} query={query} />
471
+ </span>
472
+ </div>
473
+ <span className="text-[12px] text-accent font-medium shrink-0">{item.distance}</span>
474
+ <button
475
+ onClick={(e) => { e.stopPropagation(); onFill(item.name); }}
476
+ className="text-muted-foreground/40 shrink-0 rotate-[-135deg] active:text-accent transition-colors p-[4px]"
477
+ >
478
+ <IconFont name="arrow-up" size="14px" />
479
+ </button>
480
+ </motion.button>
481
+ ))}
482
+ </motion.div>
483
+ );
484
+ }
485
+
486
+ /* ══════════════════════════════════════════
487
+ Step 3: 搜索结果列表
488
+ ══════════════════════════════════════════ */
489
+
490
+ function SearchResultList({
491
+ query,
492
+ results,
493
+ onSelectPOI,
494
+ }: {
495
+ query: string;
496
+ results: POIDetail[];
497
+ onSelectPOI: (poi: POIDetail) => void;
498
+ }) {
499
+ return (
500
+ <motion.div
501
+ key="result-list"
502
+ initial={{ opacity: 0, y: 12 }}
503
+ animate={{ opacity: 1, y: 0 }}
504
+ exit={{ opacity: 0, y: -8 }}
505
+ transition={{ duration: 0.2 }}
506
+ >
507
+ {/* 结果统计 + 筛选栏 */}
508
+ <div className="px-[var(--spacing-base)] py-[var(--spacing-xs)]">
509
+ <div className="flex items-center justify-between">
510
+ <span className="text-[13px] text-muted-foreground">
511
+ 找到 <span className="text-foreground font-medium">{results.length}</span> 个「{query}」相关结果
512
+ </span>
513
+ <div className="flex gap-[6px]">
514
+ <Tag label="综合排序" size="sm" className="bg-card-muted text-foreground border-transparent" showArrow />
515
+ <Tag label="筛选" size="sm" className="bg-card-muted text-foreground border-transparent" icon={<IconFont name="filter" size="12px" />} />
516
+ </div>
517
+ </div>
518
+ </div>
519
+
520
+ {/* POI 列表 */}
521
+ <div className="px-[var(--spacing-base)]">
522
+ {results.map((poi, index) => (
523
+ <motion.div
524
+ key={poi.id}
525
+ initial={{ opacity: 0, y: 16 }}
526
+ animate={{ opacity: 1, y: 0 }}
527
+ transition={{ delay: index * 0.06, duration: 0.3 }}
528
+ onClick={() => onSelectPOI(poi)}
529
+ className="flex gap-[var(--spacing-sm)] py-[var(--spacing-sm)] cursor-pointer active:bg-muted/20 -mx-[4px] px-[4px] rounded-[12px] transition-colors border-b border-border/30 last:border-0"
530
+ >
531
+ {/* 图片 */}
532
+ <div className="shrink-0 w-[91px] h-[91px] rounded-[12px] overflow-hidden bg-card-muted">
533
+ <ImageWithFallback src={poi.image} alt={poi.name} className="w-full h-full object-cover" />
534
+ </div>
535
+ {/* 内容 */}
536
+ <div className="flex-1 min-w-0 flex flex-col gap-[6px]">
537
+ <div className="flex items-center gap-[4px]">
538
+ <span className="text-[16px] font-medium text-foreground leading-[22px] truncate">
539
+ <HighlightText text={poi.name} query={query} />
540
+ </span>
541
+ </div>
542
+ {/* 标签 */}
543
+ <div className="flex gap-[4px] overflow-x-auto" style={{ scrollbarWidth: "none" }}>
544
+ {poi.tags.slice(0, 3).map(t => (
545
+ <span key={t} className="shrink-0 h-[20px] px-[6px] flex items-center rounded-[4px] bg-accent/8 text-accent text-[11px] font-medium">{t}</span>
546
+ ))}
547
+ </div>
548
+ {/* 评分/价格/分类 */}
549
+ <div className="flex gap-[6px] items-center text-[12px] leading-[16px] text-muted-foreground">
550
+ <span className="text-[#FF8C00] font-medium">{poi.rating}分</span>
551
+ <span className="text-border">|</span>
552
+ <span>¥{poi.pricePerPerson}/人</span>
553
+ <span className="text-border">|</span>
554
+ <span>{poi.category}</span>
555
+ </div>
556
+ {/* 距离/地址 */}
557
+ <div className="flex gap-[6px] items-center text-[12px] leading-[16px] text-muted-foreground">
558
+ <span className="text-accent font-medium">{poi.distance}</span>
559
+ <span className="truncate">{poi.address}</span>
560
+ </div>
561
+ </div>
562
+ </motion.div>
563
+ ))}
564
+ </div>
565
+ </motion.div>
566
+ );
567
+ }
568
+
569
+ /* ══════════════════════════════════════════
570
+ Step 4: POI 详情页
571
+ ══════════════════════════════════════════ */
572
+
573
+ function POIDetailView({
574
+ poi,
575
+ onBack,
576
+ onToast,
577
+ }: {
578
+ poi: POIDetail;
579
+ onBack: () => void;
580
+ onToast: (t: string) => void;
581
+ }) {
582
+ const [showShare, setShowShare] = useState(false);
583
+
584
+ return (
585
+ <motion.div
586
+ key="poi-detail"
587
+ className="absolute inset-0 z-[60] bg-background flex flex-col"
588
+ initial={{ opacity: 0, y: 20 }}
589
+ animate={{ opacity: 1, y: 0 }}
590
+ exit={{ opacity: 0, y: 20 }}
591
+ transition={{ duration: 0.25 }}
592
+ >
593
+ {/* 头部图片 */}
594
+ <div className="relative shrink-0 h-[240px] bg-card-muted">
595
+ <ImageWithFallback src={poi.image} alt={poi.name} className="w-full h-full object-cover" />
596
+ <div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-black/20" />
597
+ {/* 返回 + 操作按钮 */}
598
+ <div className="absolute top-[48px] left-0 right-0 px-[var(--spacing-base)] flex items-center justify-between">
599
+ <button onClick={onBack} className="size-[36px] rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center active:bg-black/50 transition-colors">
600
+ <IconFont name="chevron-left" size="20px" className="text-white" />
601
+ </button>
602
+ <div className="flex gap-[8px]">
603
+ <button onClick={() => setShowShare(true)} className="size-[36px] rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center active:bg-black/50 transition-colors">
604
+ <IconFont name="share" size="18px" className="text-white" />
605
+ </button>
606
+ <button onClick={() => onToast("已收藏")} className="size-[36px] rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center active:bg-black/50 transition-colors">
607
+ <IconFont name="star" size="18px" className="text-white" />
608
+ </button>
609
+ </div>
610
+ </div>
611
+ {/* 图片计数 */}
612
+ <div className="absolute bottom-[12px] right-[12px] bg-black/40 backdrop-blur-sm rounded-[12px] px-[8px] py-[3px] flex items-center gap-[4px]">
613
+ <IconFont name="image" size="12px" className="text-white/80" />
614
+ <span className="text-[11px] text-white/90">{poi.photos.length}张</span>
615
+ </div>
616
+ </div>
617
+
618
+ {/* 内容滚动区 */}
619
+ <div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: "none" }}>
620
+ {/* 基本信息 */}
621
+ <div className="px-[var(--spacing-base)] pt-[var(--spacing-base)] pb-[var(--spacing-sm)]">
622
+ <h1 className="text-[22px] font-semibold text-foreground leading-[30px]">{poi.name}</h1>
623
+ <div className="flex items-center gap-[8px] mt-[6px]">
624
+ <div className="flex items-center gap-[3px]">
625
+ <IconFont name="star" variant="filled" size="14px" className="text-[#FF8C00]" />
626
+ <span className="text-[15px] font-semibold text-[#FF8C00]">{poi.rating}</span>
627
+ <span className="text-[12px] text-muted-foreground">({poi.ratingCount}条)</span>
628
+ </div>
629
+ <span className="text-border">|</span>
630
+ <span className="text-[13px] text-foreground">¥{poi.pricePerPerson}/人</span>
631
+ <span className="text-border">|</span>
632
+ <span className="text-[13px] text-muted-foreground">{poi.category}</span>
633
+ </div>
634
+ {/* 标签 */}
635
+ <div className="flex gap-[6px] mt-[10px] overflow-x-auto" style={{ scrollbarWidth: "none" }}>
636
+ {poi.tags.map(t => (
637
+ <span key={t} className="shrink-0 h-[24px] px-[10px] flex items-center rounded-[12px] bg-accent/8 text-accent text-[12px] font-medium">{t}</span>
638
+ ))}
639
+ </div>
640
+ </div>
641
+
642
+ {/* 信息卡片 */}
643
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-sm)]">
644
+ <div className="bg-card-muted rounded-[16px] p-[var(--spacing-base)] flex flex-col gap-[var(--spacing-sm)]">
645
+ <div className="flex items-start gap-[var(--spacing-sm)]">
646
+ <IconFont name="location" size="16px" className="text-muted-foreground mt-[2px] shrink-0" />
647
+ <div className="flex-1 min-w-0">
648
+ <span className="text-[14px] text-foreground leading-[20px]">{poi.address}</span>
649
+ <span className="text-[12px] text-accent ml-[6px]">{poi.distance}</span>
650
+ </div>
651
+ <button onClick={() => onToast("已复制地址")} className="shrink-0">
652
+ <IconFont name="file-copy" size="16px" className="text-muted-foreground" />
653
+ </button>
654
+ </div>
655
+ <div className="h-px bg-border/30" />
656
+ <div className="flex items-center gap-[var(--spacing-sm)]">
657
+ <IconFont name="call" size="16px" className="text-muted-foreground shrink-0" />
658
+ <span className="text-[14px] text-accent flex-1">{poi.phone}</span>
659
+ <button onClick={() => onToast(`拨打 ${poi.phone}`)} className="shrink-0">
660
+ <IconFont name="call" size="16px" className="text-accent" />
661
+ </button>
662
+ </div>
663
+ <div className="h-px bg-border/30" />
664
+ <div className="flex items-center gap-[var(--spacing-sm)]">
665
+ <IconFont name="time" size="16px" className="text-muted-foreground shrink-0" />
666
+ <span className="text-[14px] text-foreground flex-1">营业时间 {poi.hours}</span>
667
+ <Tag label="营业中" size="xs" className="bg-[#22C55E]/10 text-[#22C55E] border-transparent" />
668
+ </div>
669
+ </div>
670
+ </div>
671
+
672
+ {/* 图片墙 */}
673
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-sm)]">
674
+ <div className="flex items-center justify-between mb-[var(--spacing-xs)]">
675
+ <span className="text-[15px] font-medium text-foreground">用户照片</span>
676
+ <button className="text-[13px] text-accent flex items-center gap-[2px]">
677
+ 全部 <IconFont name="chevron-right" size="14px" />
678
+ </button>
679
+ </div>
680
+ <div className="flex gap-[8px] overflow-x-auto" style={{ scrollbarWidth: "none" }}>
681
+ {poi.photos.map((p, i) => (
682
+ <div key={i} className="shrink-0 w-[120px] h-[90px] rounded-[12px] overflow-hidden bg-card-muted">
683
+ <ImageWithFallback src={p} alt={`${poi.name} ${i + 1}`} className="w-full h-full object-cover" />
684
+ </div>
685
+ ))}
686
+ </div>
687
+ </div>
688
+
689
+ {/* 周边推荐 placeholder */}
690
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-base)]">
691
+ <div className="flex items-center gap-[6px] mb-[var(--spacing-xs)]">
692
+ <IconFont name="map-marked" size="15px" className="text-accent" />
693
+ <span className="text-[15px] font-medium text-foreground">周边推荐</span>
694
+ </div>
695
+ <div className="grid grid-cols-2 gap-[8px]">
696
+ {["附近美食", "周边景点", "酒店住宿", "交通出行"].map(label => (
697
+ <button
698
+ key={label}
699
+ onClick={() => onToast(`查看${label}`)}
700
+ className="flex items-center gap-[8px] p-[12px] bg-card-muted rounded-[12px] active:bg-muted transition-colors"
701
+ >
702
+ <IconFont
703
+ name={label === "附近美食" ? "fork" : label === "周边景点" ? "map-marked" : label === "酒店住宿" ? "building" : "vehicle"}
704
+ size="18px"
705
+ className="text-muted-foreground"
706
+ />
707
+ <span className="text-[13px] text-foreground font-medium">{label}</span>
708
+ </button>
709
+ ))}
710
+ </div>
711
+ </div>
712
+
713
+ {/* 底部留白 */}
714
+ <div className="h-[80px]" />
715
+ </div>
716
+
717
+ {/* 底部操作栏 */}
718
+ <div className="shrink-0 border-t border-border/30 bg-background px-[var(--spacing-base)] py-[var(--spacing-sm)] pb-[max(var(--spacing-sm),env(safe-area-inset-bottom))]">
719
+ <div className="flex gap-[var(--spacing-xs)]">
720
+ <button onClick={() => onToast(`拨打 ${poi.phone}`)} className="size-[48px] rounded-full bg-card-muted flex items-center justify-center shrink-0 active:bg-muted transition-colors">
721
+ <IconFont name="call" size="20px" className="text-foreground" />
722
+ </button>
723
+ <button onClick={() => onToast("已收藏")} className="size-[48px] rounded-full bg-card-muted flex items-center justify-center shrink-0 active:bg-muted transition-colors">
724
+ <IconFont name="star" size="20px" className="text-foreground" />
725
+ </button>
726
+ <Btn size="large" variant="primary" label="到这去" icon={<IconFont name="map-navigation" size="18px" />} onClick={() => onToast(`正在导航至${poi.name}...`)} className="flex-1" />
727
+ </div>
728
+ </div>
729
+
730
+ {/* 分享弹窗 */}
731
+ <BottomSheet
732
+ open={showShare}
733
+ onOpenChange={setShowShare}
734
+ title="分享"
735
+ mainActionText="取消"
736
+ secondaryActionText="复制链接"
737
+ onMainAction={() => setShowShare(false)}
738
+ onSecondaryAction={() => { onToast("链接已复制"); setShowShare(false); }}
739
+ />
740
+ </motion.div>
741
+ );
742
+ }
743
+
744
+ /* ══════════════════════════════════════════
745
+ 主组件
746
+ ══════════════════════════════════════════ */
747
+
748
+ interface SearchFlowDemoProps {
749
+ onBack?: () => void;
750
+ }
751
+
752
+ export function SearchFlowDemo({ onBack }: SearchFlowDemoProps) {
753
+ const [step, setStep] = useState<FlowStep>("home");
754
+ const [searchValue, setSearchValue] = useState("");
755
+ const [confirmedQuery, setConfirmedQuery] = useState("");
756
+ const [toastText, setToastText] = useState<string | null>(null);
757
+ const [history, setHistory] = useState(searchHistory);
758
+ const [selectedPOI, setSelectedPOI] = useState<POIDetail | null>(null);
759
+ const [activeResultTab, setActiveResultTab] = useState("全部");
760
+ const inputRef = useRef<HTMLInputElement>(null);
761
+ const isSearchingRef = useRef(false);
762
+
763
+ const showToast = useCallback((t: string) => setToastText(t), []);
764
+ useEffect(() => { if (!toastText) return; const id = setTimeout(() => setToastText(null), 2200); return () => clearTimeout(id); }, [toastText]);
765
+
766
+ /* Sug 过滤 */
767
+ const suggestions = useMemo(() => {
768
+ if (!searchValue.trim()) return [];
769
+ const q = searchValue.trim().toLowerCase();
770
+ return allSuggestions.filter(s => s.name.toLowerCase().includes(q) || s.address.toLowerCase().includes(q));
771
+ }, [searchValue]);
772
+
773
+ /* 搜索结果过滤 — 同时考虑 confirmedQuery 和 activeResultTab */
774
+ const searchResults = useMemo(() => {
775
+ let results = searchResultPOIs;
776
+ // 先按搜索词过滤
777
+ if (confirmedQuery.trim()) {
778
+ const q = confirmedQuery.trim().toLowerCase();
779
+ const filtered = searchResultPOIs.filter(p => p.name.toLowerCase().includes(q) || p.category.toLowerCase().includes(q) || p.tags.some(t => t.toLowerCase().includes(q)));
780
+ if (filtered.length > 0) results = filtered;
781
+ }
782
+ // 再按 tab 过滤
783
+ if (activeResultTab !== "全部") {
784
+ const tabFiltered = results.filter(p => p.category === activeResultTab);
785
+ if (tabFiltered.length > 0) results = tabFiltered;
786
+ }
787
+ return results;
788
+ }, [confirmedQuery, activeResultTab]);
789
+
790
+ /* 当用户手动输入变化时,切换到 sug(doSearch 触发的不受影响) */
791
+ useEffect(() => {
792
+ if (isSearchingRef.current) {
793
+ isSearchingRef.current = false;
794
+ return;
795
+ }
796
+ if (searchValue.trim() && (step === "home" || step === "result")) {
797
+ setStep("sug");
798
+ }
799
+ if (!searchValue.trim() && step === "sug") {
800
+ setStep("home");
801
+ }
802
+ }, [searchValue, step]);
803
+
804
+ /* 结果页 tab 列表 */
805
+ const resultTabs = ["全部", "美食", "咖啡", "酒店", "加油站", "停车场", "电影", "景点"];
806
+
807
+ /* 确认搜索 → 跳到结果 */
808
+ const doSearch = useCallback((query: string) => {
809
+ if (!query.trim()) return;
810
+ isSearchingRef.current = true;
811
+ setSearchValue(query);
812
+ setConfirmedQuery(query);
813
+ setStep("result");
814
+ // 自动匹配 tab
815
+ const q = query.trim();
816
+ const matchedTab = resultTabs.find(t => t !== "全部" && t === q);
817
+ setActiveResultTab(matchedTab ?? "全部");
818
+ // 加入历史
819
+ setHistory(prev => {
820
+ const filtered = prev.filter(h => h !== query);
821
+ return [query, ...filtered].slice(0, 8);
822
+ });
823
+ }, []);
824
+
825
+ /* Sug 项选中 → 直接搜索 */
826
+ const handleSugSelect = useCallback((item: SugItem) => {
827
+ doSearch(item.name);
828
+ }, [doSearch]);
829
+
830
+ /* POI 选中 → 详情 */
831
+ const handleSelectPOI = useCallback((poi: POIDetail) => {
832
+ setSelectedPOI(poi);
833
+ setStep("detail");
834
+ }, []);
835
+
836
+ /* 返回逻辑 */
837
+ const handleBack = useCallback(() => {
838
+ if (step === "detail") {
839
+ setStep("result");
840
+ setSelectedPOI(null);
841
+ } else if (step === "result") {
842
+ setStep("sug");
843
+ } else if (step === "sug") {
844
+ setSearchValue("");
845
+ setStep("home");
846
+ } else {
847
+ onBack?.();
848
+ }
849
+ }, [step, onBack]);
850
+
851
+ return (
852
+ <div className="relative w-full h-full flex flex-col bg-white" style={{ fontFamily: "var(--font-family-sans)" }}>
853
+ {/* 渐变背景 */}
854
+ <div className="absolute inset-0 bg-gradient-to-b from-[#EEF2F9] via-[#F4F6FB] to-white" />
855
+
856
+ {/* 主内容 */}
857
+ <div className="relative z-10 flex-1 flex flex-col overflow-hidden">
858
+ {/* 顶部搜索栏 */}
859
+ {step !== "detail" && (
860
+ <div className="relative z-50 pt-[12px] px-[var(--spacing-base)] pb-[var(--spacing-xs)]">
861
+ <SearchBox
862
+ value={searchValue}
863
+ onChange={setSearchValue}
864
+ variant="card"
865
+ placeholder="搜索地点、公交、地铁"
866
+ onBack={handleBack}
867
+ onSearch={() => doSearch(searchValue)}
868
+ onMic={() => showToast("语音识别中...")}
869
+ onClear={() => { setSearchValue(""); setStep("home"); }}
870
+ className="bg-card-muted"
871
+ />
872
+ </div>
873
+ )}
874
+
875
+ {/* 结果页 tab 栏 */}
876
+ {step === "result" && (
877
+ <motion.div
878
+ className="relative z-40 px-[var(--spacing-base)] pb-[var(--spacing-xs)]"
879
+ initial={{ opacity: 0, y: -8 }}
880
+ animate={{ opacity: 1, y: 0 }}
881
+ >
882
+ <div className="flex gap-[6px] overflow-x-auto" style={{ scrollbarWidth: "none" }}>
883
+ {resultTabs.map((tab) => (
884
+ <button
885
+ key={tab}
886
+ onClick={() => setActiveResultTab(tab)}
887
+ className={cn(
888
+ "shrink-0 h-[32px] px-[14px] rounded-[16px] text-[13px] font-medium transition-colors",
889
+ activeResultTab === tab ? "bg-primary text-white" : "bg-card-muted text-foreground active:bg-muted"
890
+ )}
891
+ >
892
+ {tab}
893
+ </button>
894
+ ))}
895
+ </div>
896
+ </motion.div>
897
+ )}
898
+
899
+ {/* 内容区 */}
900
+ <div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: "none" }}>
901
+ <AnimatePresence mode="wait">
902
+ {step === "home" && (
903
+ <SearchHome
904
+ history={history}
905
+ onSelectHistory={text => doSearch(text)}
906
+ onRemoveHistory={text => setHistory(prev => prev.filter(h => h !== text))}
907
+ onClearHistory={() => { setHistory([]); showToast("搜索历史已清空"); }}
908
+ onSelectCategory={label => doSearch(label)}
909
+ onSelectHot={text => doSearch(text)}
910
+ />
911
+ )}
912
+ {step === "sug" && (
913
+ <SugList
914
+ suggestions={suggestions}
915
+ query={searchValue.trim()}
916
+ onSelect={handleSugSelect}
917
+ onFill={(name) => setSearchValue(name)}
918
+ />
919
+ )}
920
+ {step === "result" && (
921
+ <SearchResultList
922
+ query={confirmedQuery}
923
+ results={searchResults}
924
+ onSelectPOI={handleSelectPOI}
925
+ />
926
+ )}
927
+ </AnimatePresence>
928
+ </div>
929
+ </div>
930
+
931
+ {/* POI 详情页覆盖层 */}
932
+ <AnimatePresence>
933
+ {step === "detail" && selectedPOI && (
934
+ <POIDetailView
935
+ poi={selectedPOI}
936
+ onBack={() => { setStep("result"); setSelectedPOI(null); }}
937
+ onToast={showToast}
938
+ />
939
+ )}
940
+ </AnimatePresence>
941
+
942
+ {/* Toast */}
943
+ <div className="fixed top-[120px] left-0 right-0 z-[200] flex justify-center pointer-events-none">
944
+ <AnimatePresence>
945
+ {toastText && (
946
+ <div className="pointer-events-auto">
947
+ <Toast lines={[toastText]} showIcon showClose onClose={() => setToastText(null)} />
948
+ </div>
949
+ )}
950
+ </AnimatePresence>
951
+ </div>
952
+ </div>
953
+ );
954
+ }