@lmy54321/design-system 1.1.4 → 1.3.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,1014 @@
1
+ import React, { useState, useCallback, useEffect } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+ import { cn } from "@lmy54321/design-system";
4
+ import { Tag } from "@lmy54321/design-system";
5
+ import { IconFont } from "@lmy54321/design-system";
6
+ import { BottomNavigationBar } from "@lmy54321/design-system";
7
+ import type { TabId } from "@lmy54321/design-system";
8
+ import { ImageWithFallback } from "@lmy54321/design-system";
9
+ import { DraggablePanel, DRAWER_STATES } from "@lmy54321/design-system";
10
+ import { TencentMap } from "@lmy54321/design-system";
11
+ import { Btn } from "@lmy54321/design-system";
12
+ import { Toast } from "@lmy54321/design-system";
13
+ import { RouteDetailPage } from "./RouteDetailPage";
14
+ import type { RouteItem } from "./RouteDetailPage";
15
+ import { cdnImage } from "../config/cdn";
16
+
17
+ /* ── 筛选标签 ── */
18
+ const categories = [
19
+ { label: "推荐", icon: "star" },
20
+ { label: "路线", icon: "map-navigation" },
21
+ { label: "美食", icon: "fork" },
22
+ { label: "景点", icon: "map-marked" },
23
+ { label: "咖啡", icon: "tea" },
24
+ { label: "购物", icon: "shop" },
25
+ { label: "文化", icon: "museum" },
26
+ { label: "亲子", icon: "heart" },
27
+ ];
28
+
29
+ /* ── 地图标注 POI ── */
30
+ const mapPOIs = [
31
+ { name: "故宫", lat: 39.916, lng: 116.397, top: "22%", left: "48%" },
32
+ { name: "南锣鼓巷", lat: 39.937, lng: 116.403, top: "18%", left: "56%" },
33
+ { name: "798", lat: 39.984, lng: 116.494, top: "12%", left: "72%" },
34
+ { name: "三里屯", lat: 39.932, lng: 116.454, top: "26%", left: "62%" },
35
+ { name: "什刹海", lat: 39.942, lng: 116.383, top: "16%", left: "40%" },
36
+ ];
37
+
38
+ /* ── 路线推荐数据 ── */
39
+ export const routeRecommendations: RouteItem[] = [
40
+ {
41
+ id: "r1",
42
+ title: "一天走完中轴线!累但值得",
43
+ desc: "从永定门一路暴走到钟鼓楼,把北京城600年的底蕴踩在脚下,步数3w+但每一步都是历史",
44
+ image: cdnImage("/images/feed-axis.png"),
45
+ duration: "1天",
46
+ distance: "7.8km",
47
+ stops: [
48
+ { name: "永定门", icon: "map-marked", dur: "30min" },
49
+ { name: "天坛公园", icon: "map-marked", dur: "1.5h" },
50
+ { name: "天安门广场", icon: "map-marked", dur: "1h" },
51
+ { name: "故宫博物院", icon: "museum", dur: "3h" },
52
+ { name: "景山公园", icon: "map-marked", dur: "1h" },
53
+ { name: "钟鼓楼", icon: "map-marked", dur: "40min" },
54
+ ],
55
+ rating: "4.9",
56
+ saves: 5832,
57
+ tag: "经典",
58
+ tagColor: "#367BF6",
59
+ author: "在逃的建筑系学生",
60
+ },
61
+ {
62
+ id: "r2",
63
+ title: "什刹海半日闲逛|拍照巨出片",
64
+ desc: "恭王府逛完溜达到后海,路上随便一拍就是大片,咖啡也绝绝子",
65
+ image: cdnImage("/images/feed/route-shichahai.png"),
66
+ duration: "半天",
67
+ distance: "3.2km",
68
+ stops: [
69
+ { name: "恭王府", icon: "museum", dur: "1.5h" },
70
+ { name: "烟袋斜街", icon: "shop", dur: "40min" },
71
+ { name: "银锭桥", icon: "map-marked", dur: "20min" },
72
+ { name: "后海酒吧街", icon: "drink", dur: "1h" },
73
+ ],
74
+ rating: "4.7",
75
+ saves: 3216,
76
+ tag: "文艺",
77
+ tagColor: "#6C5CE7",
78
+ author: "胡同串子阿瑶",
79
+ },
80
+ {
81
+ id: "r3",
82
+ title: "朝阳觅食一整天!从brunch吃到宵夜",
83
+ desc: "早上国贸吃brunch,中午三里屯逛吃,晚上望京撸串,这条线我走了不下5遍",
84
+ image: cdnImage("/images/feed/route-food-cbd.png"),
85
+ duration: "1天",
86
+ distance: "4.5km",
87
+ stops: [
88
+ { name: "国贸·Brunch", icon: "fork", dur: "1h" },
89
+ { name: "三里屯太古里", icon: "shop", dur: "2h" },
90
+ { name: "亮马桥官舍", icon: "tea", dur: "40min" },
91
+ { name: "望京小腰", icon: "fork", dur: "1.5h" },
92
+ { name: "团结湖居酒屋", icon: "drink", dur: "1h" },
93
+ ],
94
+ rating: "4.8",
95
+ saves: 4107,
96
+ tag: "干饭",
97
+ tagColor: "#FF6B35",
98
+ author: "朝阳干饭王小陈",
99
+ },
100
+ {
101
+ id: "r4",
102
+ title: "京郊自驾赏秋|美哭了真的",
103
+ desc: "坡峰岭的红叶太震撼了!拍了300张照片,随手一张都是壁纸级别",
104
+ image: cdnImage("/images/feed/route-autumn.png"),
105
+ duration: "1天",
106
+ distance: "120km",
107
+ stops: [
108
+ { name: "坡峰岭红叶", icon: "cherry", dur: "2h" },
109
+ { name: "十渡风景区", icon: "map-marked", dur: "1.5h" },
110
+ { name: "野三坡百里峡", icon: "map-marked", dur: "2h" },
111
+ { name: "拒马河漂流", icon: "map-marked", dur: "1h" },
112
+ ],
113
+ rating: "4.6",
114
+ saves: 2890,
115
+ tag: "自驾",
116
+ tagColor: "#10B981",
117
+ author: "周末出逃计划",
118
+ },
119
+ ];
120
+
121
+ /* ── 瀑布流帖子(丰富版) ── */
122
+ type FeedItem = {
123
+ id: string;
124
+ type: "article" | "route";
125
+ image: string;
126
+ title: string;
127
+ content: string;
128
+ author: string;
129
+ authorAvatar: string;
130
+ authorLevel?: string;
131
+ likes: number;
132
+ comments: number;
133
+ shares: number;
134
+ aspectH: number;
135
+ routeId?: string;
136
+ tags: string[];
137
+ location?: string;
138
+ publishTime: string;
139
+ isLiked?: boolean;
140
+ isCollected?: boolean;
141
+ };
142
+
143
+ const feeds: FeedItem[] = [
144
+ {
145
+ id: "1",
146
+ type: "article",
147
+ image: cdnImage("/images/feed-hutong.png"),
148
+ title: "救命!北京胡同也太好逛了吧",
149
+ content: "从南锣鼓巷一路逛到五道营,每条胡同都有惊喜!墙上的涂鸦、藏在深处的咖啡馆、老北京四合院的门墩...",
150
+ author: "迷路的南方人",
151
+ authorAvatar: "https://picsum.photos/seed/avatar-1/100/100",
152
+ authorLevel: "本地达人",
153
+ likes: 2463,
154
+ comments: 342,
155
+ shares: 128,
156
+ aspectH: 180,
157
+ tags: ["胡同", "拍照"],
158
+ location: "南锣鼓巷",
159
+ publishTime: "3小时前",
160
+ },
161
+ {
162
+ id: "2",
163
+ type: "route",
164
+ image: cdnImage("/images/feed-axis.png"),
165
+ title: "一天走完中轴线!累但值得",
166
+ content: "从永定门一路暴走到钟鼓楼,步数3w+但每一步都值了!",
167
+ author: "在逃的建筑系学生",
168
+ authorAvatar: "https://picsum.photos/seed/avatar-2/100/100",
169
+ authorLevel: "路线达人",
170
+ likes: 5832,
171
+ comments: 891,
172
+ shares: 567,
173
+ aspectH: 150,
174
+ routeId: "r1",
175
+ tags: ["中轴线", "暴走"],
176
+ location: "故宫",
177
+ publishTime: "昨天",
178
+ },
179
+ {
180
+ id: "3",
181
+ type: "article",
182
+ image: cdnImage("/images/feed-coffee-sanlitun.png"),
183
+ title: "三里屯这家咖啡也太绝了|环境氛围感拉满",
184
+ content: "被种草很久终于来了!loft风的装修超有feel,拿铁拉花巨好看,坐窗边随手一拍就是大片",
185
+ author: "每日一杯美式续命",
186
+ authorAvatar: "https://picsum.photos/seed/avatar-3/100/100",
187
+ likes: 1287,
188
+ comments: 156,
189
+ shares: 89,
190
+ aspectH: 130,
191
+ tags: ["咖啡", "氛围感"],
192
+ location: "三里屯太古里",
193
+ publishTime: "5小时前",
194
+ },
195
+ {
196
+ id: "4",
197
+ type: "route",
198
+ image: cdnImage("/images/feed/route-food-cbd.png"),
199
+ title: "朝阳觅食一整天!从brunch吃到宵夜",
200
+ content: "早上国贸brunch,中午三里屯逛吃,晚上望京撸串,这条线走了不下5遍",
201
+ author: "朝阳干饭王小陈",
202
+ authorAvatar: "https://picsum.photos/seed/avatar-4/100/100",
203
+ authorLevel: "美食博主",
204
+ likes: 4107,
205
+ comments: 623,
206
+ shares: 412,
207
+ aspectH: 145,
208
+ routeId: "r3",
209
+ tags: ["美食", "觅食"],
210
+ location: "朝阳区",
211
+ publishTime: "2天前",
212
+ },
213
+ {
214
+ id: "5",
215
+ type: "article",
216
+ image: cdnImage("/images/feed/feed-autumn.png"),
217
+ title: "京郊红叶拍照攻略|朋友圈直接炸了",
218
+ content: "坡峰岭的红叶真的太震撼了!分享几个绝佳机位,随手拍都是壁纸级别",
219
+ author: "周末出逃计划",
220
+ authorAvatar: "https://picsum.photos/seed/avatar-5/100/100",
221
+ authorLevel: "摄影达人",
222
+ likes: 3891,
223
+ comments: 467,
224
+ shares: 289,
225
+ aspectH: 170,
226
+ tags: ["红叶", "拍照攻略"],
227
+ location: "坡峰岭",
228
+ publishTime: "3天前",
229
+ },
230
+ {
231
+ id: "6",
232
+ type: "article",
233
+ image: cdnImage("/images/feed/feed-museum.png"),
234
+ title: "故宫这个展太绝了!冲冲冲",
235
+ content: "千秋佳人特展,展品超精美!建议留出半天时间慢慢看,记得提前预约",
236
+ author: "博物馆女孩日记",
237
+ authorAvatar: "https://picsum.photos/seed/avatar-6/100/100",
238
+ authorLevel: "文化博主",
239
+ likes: 5102,
240
+ comments: 734,
241
+ shares: 501,
242
+ aspectH: 140,
243
+ tags: ["故宫", "展览"],
244
+ location: "故宫博物院",
245
+ publishTime: "1天前",
246
+ },
247
+ {
248
+ id: "7",
249
+ type: "route",
250
+ image: cdnImage("/images/feed/route-shichahai.png"),
251
+ title: "什刹海半日闲逛|拍照巨出片",
252
+ content: "恭王府逛完溜达到后海,路上随便一拍就是大片",
253
+ author: "胡同串子阿瑶",
254
+ authorAvatar: "https://picsum.photos/seed/avatar-7/100/100",
255
+ likes: 3216,
256
+ comments: 398,
257
+ shares: 234,
258
+ aspectH: 148,
259
+ routeId: "r2",
260
+ tags: ["什刹海", "出片"],
261
+ location: "什刹海",
262
+ publishTime: "4天前",
263
+ },
264
+ {
265
+ id: "8",
266
+ type: "article",
267
+ image: cdnImage("/images/feed/feed-food.png"),
268
+ title: "簋街深夜暴走!吃到扶墙出系列",
269
+ content: "从东直门一路吃到北新桥,小龙虾、烤鱼、卤煮、炒肝…热量炸弹一晚上全补回来了",
270
+ author: "深夜放毒小分队",
271
+ authorAvatar: "https://picsum.photos/seed/avatar-8/100/100",
272
+ authorLevel: "吃货达人",
273
+ likes: 4215,
274
+ comments: 589,
275
+ shares: 367,
276
+ aspectH: 160,
277
+ tags: ["簋街", "深夜美食"],
278
+ location: "簋街",
279
+ publishTime: "6小时前",
280
+ },
281
+ {
282
+ id: "9",
283
+ type: "article",
284
+ image: cdnImage("/images/feed/feed-temple.png"),
285
+ title: "雍和宫求啥最灵?本地人来说说",
286
+ content: "作为北京土著分享一下经验:求事业去法轮殿,求学业去万福阁,求姻缘去…",
287
+ author: "佛系北京妞",
288
+ authorAvatar: "https://picsum.photos/seed/avatar-9/100/100",
289
+ likes: 1893,
290
+ comments: 412,
291
+ shares: 178,
292
+ aspectH: 125,
293
+ tags: ["雍和宫", "攻略"],
294
+ location: "雍和宫",
295
+ publishTime: "2天前",
296
+ },
297
+ {
298
+ id: "10",
299
+ type: "route",
300
+ image: cdnImage("/images/feed/route-autumn.png"),
301
+ title: "京郊自驾赏秋|美哭了真的",
302
+ content: "坡峰岭红叶太震撼!拍了300张照片随手一张都是壁纸",
303
+ author: "周末出逃计划",
304
+ authorAvatar: "https://picsum.photos/seed/avatar-10/100/100",
305
+ likes: 2890,
306
+ comments: 321,
307
+ shares: 245,
308
+ aspectH: 150,
309
+ routeId: "r4",
310
+ tags: ["自驾", "赏秋"],
311
+ location: "房山区",
312
+ publishTime: "5天前",
313
+ },
314
+ {
315
+ id: "11",
316
+ type: "article",
317
+ image: cdnImage("/images/feed/feed-park.png"),
318
+ title: "朝阳公园遛娃天花板!孩子玩疯了",
319
+ content: "带娃来了N次了,沙坑、滑梯、草坪野餐…一待就是一整天,大人也能放松",
320
+ author: "俩娃妈的日常",
321
+ authorAvatar: "https://picsum.photos/seed/avatar-11/100/100",
322
+ likes: 967,
323
+ comments: 134,
324
+ shares: 89,
325
+ aspectH: 165,
326
+ tags: ["遛娃", "亲子"],
327
+ location: "朝阳公园",
328
+ publishTime: "1天前",
329
+ },
330
+ {
331
+ id: "12",
332
+ type: "article",
333
+ image: cdnImage("/images/feed/feed-art.png"),
334
+ title: "798这几个展我愿称之为年度最佳",
335
+ content: "UCCA尤伦斯的新展、木木美术馆的沉浸式体验、还有长征空间…周末花一天刷完",
336
+ author: "艺术民工小王",
337
+ authorAvatar: "https://picsum.photos/seed/avatar-12/100/100",
338
+ authorLevel: "艺术达人",
339
+ likes: 2340,
340
+ comments: 278,
341
+ shares: 198,
342
+ aspectH: 135,
343
+ tags: ["798", "展览"],
344
+ location: "798艺术区",
345
+ publishTime: "3天前",
346
+ },
347
+ ];
348
+
349
+ interface ExplorePageProps {
350
+ activeTab: TabId;
351
+ onTabChange: (id: TabId) => void;
352
+ onCreatePlan?: (plan: { title: string; spots: number; image: string; stops: { name: string; icon: string; dur: string }[] }) => void;
353
+ }
354
+
355
+ export function ExplorePage({ activeTab, onTabChange, onCreatePlan }: ExplorePageProps) {
356
+ const [selected, setSelected] = useState("推荐");
357
+ const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
358
+ const [panelState, setPanelState] = useState(DRAWER_STATES.MEDIUM);
359
+ const [toast, setToast] = useState<string | null>(null);
360
+ const [likedPosts, setLikedPosts] = useState<Set<string>>(new Set());
361
+ const [selectedPost, setSelectedPost] = useState<FeedItem | null>(null);
362
+
363
+ const showToast = useCallback((t: string) => setToast(t), []);
364
+ useEffect(() => {
365
+ if (!toast) return;
366
+ const id = setTimeout(() => setToast(null), 2200);
367
+ return () => clearTimeout(id);
368
+ }, [toast]);
369
+
370
+ const toggleLike = useCallback((postId: string, e: React.MouseEvent) => {
371
+ e.stopPropagation();
372
+ setLikedPosts(prev => {
373
+ const next = new Set(prev);
374
+ if (next.has(postId)) {
375
+ next.delete(postId);
376
+ } else {
377
+ next.add(postId);
378
+ }
379
+ return next;
380
+ });
381
+ }, []);
382
+
383
+ const filteredFeeds = selected === "路线"
384
+ ? feeds.filter(f => f.type === "route")
385
+ : selected === "美食"
386
+ ? feeds.filter(f => f.tags.some(t => ["美食", "觅食", "深夜美食", "簋街", "咖啡"].includes(t)) || f.type === "route" && f.routeId === "r3")
387
+ : selected === "景点"
388
+ ? feeds.filter(f => f.tags.some(t => ["故宫", "展览", "雍和宫", "798"].includes(t)))
389
+ : selected === "咖啡"
390
+ ? feeds.filter(f => f.tags.some(t => ["咖啡", "氛围感"].includes(t)))
391
+ : selected === "文化"
392
+ ? feeds.filter(f => f.tags.some(t => ["故宫", "展览", "798", "雍和宫", "博物馆"].includes(t)))
393
+ : feeds;
394
+
395
+ /* 路线详情页 */
396
+ if (selectedRoute) {
397
+ return (
398
+ <RouteDetailPage
399
+ route={selectedRoute}
400
+ onBack={() => setSelectedRoute(null)}
401
+ onCreatePlan={(plan) => {
402
+ setSelectedRoute(null);
403
+ onCreatePlan?.(plan);
404
+ }}
405
+ />
406
+ );
407
+ }
408
+
409
+ /* 帖子详情页 */
410
+ if (selectedPost) {
411
+ const isRoute = selectedPost.type === "route";
412
+ const route = isRoute ? routeRecommendations.find(r => r.id === selectedPost.routeId) : null;
413
+ return (
414
+ <PostDetailView
415
+ post={selectedPost}
416
+ route={route ?? null}
417
+ isLiked={likedPosts.has(selectedPost.id)}
418
+ onToggleLike={() => {
419
+ setLikedPosts(prev => {
420
+ const next = new Set(prev);
421
+ if (next.has(selectedPost.id)) next.delete(selectedPost.id);
422
+ else next.add(selectedPost.id);
423
+ return next;
424
+ });
425
+ }}
426
+ onBack={() => setSelectedPost(null)}
427
+ onRouteClick={(r) => { setSelectedPost(null); setSelectedRoute(r); }}
428
+ onToast={showToast}
429
+ />
430
+ );
431
+ }
432
+
433
+ return (
434
+ <div
435
+ className="relative w-full h-[100dvh] overflow-hidden bg-background"
436
+ style={{ fontFamily: "var(--font-family-sans)" }}
437
+ >
438
+ {/* 1. 腾讯地图底图 */}
439
+ <TencentMap
440
+ className="absolute inset-0 w-full h-full"
441
+ center={{ lat: 39.929, lng: 116.417 }}
442
+ zoom={13}
443
+ pitch={40}
444
+ rotation={0}
445
+ />
446
+
447
+ {/* 地图上的热门标注点 */}
448
+ <div className="absolute inset-0 pointer-events-none z-[5]">
449
+ {mapPOIs.map((poi) => (
450
+ <motion.div
451
+ key={poi.name}
452
+ className="absolute flex flex-col items-center pointer-events-auto"
453
+ style={{ top: poi.top, left: poi.left, transform: "translate(-50%,-100%)" }}
454
+ initial={{ opacity: 0, scale: 0.5, y: 10 }}
455
+ animate={{ opacity: 1, scale: 1, y: 0 }}
456
+ transition={{ delay: 0.2, duration: 0.4, type: "spring" }}
457
+ >
458
+ <div className="bg-accent text-white text-[10px] font-medium px-[6px] py-[2px] rounded-[6px] mb-[3px] whitespace-nowrap shadow-sm">
459
+ {poi.name}
460
+ </div>
461
+ <div className="size-[10px] rounded-full bg-accent shadow-[0_0_0_3px_rgba(54,123,246,0.2)]" />
462
+ </motion.div>
463
+ ))}
464
+ </div>
465
+
466
+ {/* 右侧地图控制 */}
467
+ <motion.div
468
+ className="absolute right-[16px] top-[56px] z-40 flex flex-col gap-[8px] items-center"
469
+ initial={{ x: 20, opacity: 0 }}
470
+ animate={{ x: 0, opacity: 1 }}
471
+ transition={{ delay: 0.3 }}
472
+ >
473
+ <button
474
+ className="size-[40px] rounded-[20px] bg-card/80 shadow-[0px_4px_16px_0px_rgba(0,0,0,0.10)] backdrop-blur-md border border-white/40 flex items-center justify-center active:bg-card/60 transition-colors"
475
+ onClick={() => showToast("切换地图图层")}
476
+ >
477
+ <IconFont name="layers" size="20px" className="text-foreground" />
478
+ </button>
479
+ <button
480
+ className="size-[40px] rounded-[20px] bg-card/80 shadow-[0px_4px_16px_0px_rgba(0,0,0,0.10)] backdrop-blur-md border border-white/40 flex items-center justify-center active:bg-card/60 transition-colors"
481
+ onClick={() => showToast("定位到当前位置")}
482
+ >
483
+ <IconFont name="focus" size="20px" className="text-accent" />
484
+ </button>
485
+ </motion.div>
486
+
487
+ {/* 2. 底部导航栏 — 覆盖在面板上方 */}
488
+ <div className="absolute bottom-[12px] left-1/2 -translate-x-1/2 z-[1001]">
489
+ <BottomNavigationBar activeTab={activeTab} onTabChange={onTabChange} />
490
+ </div>
491
+
492
+ {/* 3. DraggablePanel 三段式面板 */}
493
+ <DraggablePanel
494
+ state={panelState}
495
+ onStateChange={setPanelState}
496
+ customSmallHeight={260}
497
+ customMediumHeight={480}
498
+ bottomOffset={8}
499
+ >
500
+ {/* 面板头部:标题 + 筛选标签 */}
501
+ <div className="shrink-0">
502
+ <div className="flex items-center justify-between px-[16px] pb-[8px]">
503
+ <span className="text-[18px] font-semibold text-foreground">探索</span>
504
+ <div className="flex items-center gap-[4px] text-[12px] text-muted-foreground">
505
+ <IconFont name="location" size="13px" />
506
+ <span>北京</span>
507
+ </div>
508
+ </div>
509
+
510
+ {/* 筛选标签 */}
511
+ <div className="px-[16px] pb-[12px]">
512
+ <div className="flex gap-[8px] overflow-x-auto scrollbar-hide -mx-[16px] px-[16px]">
513
+ {categories.map((cat) => (
514
+ <button
515
+ key={cat.label}
516
+ onClick={() => setSelected(cat.label)}
517
+ className={cn(
518
+ "shrink-0 flex items-center gap-[4px] px-[14px] py-[6px] rounded-full text-[13px] font-medium transition-all",
519
+ selected === cat.label
520
+ ? "bg-primary text-white shadow-sm"
521
+ : "bg-black/[0.04] text-muted-foreground active:bg-black/[0.08]"
522
+ )}
523
+ >
524
+ <IconFont name={cat.icon} size="13px" />
525
+ {cat.label}
526
+ </button>
527
+ ))}
528
+ </div>
529
+ </div>
530
+ </div>
531
+
532
+ {/* 可滚动区域 */}
533
+ <div className="flex flex-col h-full min-h-0">
534
+ <div className="flex-1 min-h-0 overflow-y-auto">
535
+ {/* 附近热门 快速横滑 */}
536
+ <div className="px-[16px] pb-[12px]">
537
+ <div className="flex items-center justify-between mb-[8px]">
538
+ <span className="text-[14px] font-medium text-foreground">附近热门</span>
539
+ <Tag
540
+ label="更多"
541
+ size="sm"
542
+ showArrow
543
+ className="bg-black/[0.04] text-muted-foreground border-transparent"
544
+ />
545
+ </div>
546
+ <div className="flex gap-[10px] overflow-x-auto scrollbar-hide -mx-[16px] px-[16px] pb-[4px]">
547
+ {feeds.slice(0, 5).map((item, i) => (
548
+ <motion.div
549
+ key={`hot-${item.id}`}
550
+ className="shrink-0 w-[140px] rounded-[14px] overflow-hidden bg-card-muted cursor-pointer active:scale-[0.98] transition-transform"
551
+ initial={{ opacity: 0, x: 15 }}
552
+ animate={{ opacity: 1, x: 0 }}
553
+ transition={{ delay: 0.04 * i, duration: 0.3 }}
554
+ onClick={() => setSelectedPost(item)}
555
+ >
556
+ <div className="relative h-[100px]">
557
+ <ImageWithFallback src={item.image} alt={item.title} className="w-full h-full object-cover" />
558
+ <div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
559
+ {item.location && (
560
+ <div className="absolute bottom-[6px] left-[6px] flex items-center gap-[2px]">
561
+ <IconFont name="location" size="9px" className="text-white/90" />
562
+ <span className="text-[9px] text-white/90 font-medium">{item.location}</span>
563
+ </div>
564
+ )}
565
+ </div>
566
+ <div className="p-[8px]">
567
+ <p className="text-[12px] font-medium text-foreground leading-[16px] line-clamp-2">{item.title}</p>
568
+ <div className="flex items-center gap-[3px] mt-[4px]">
569
+ <div className="size-[14px] rounded-full overflow-hidden shrink-0">
570
+ <ImageWithFallback src={item.authorAvatar} alt="" className="size-full object-cover" />
571
+ </div>
572
+ <span className="text-[10px] text-muted-foreground truncate">{item.author}</span>
573
+ </div>
574
+ </div>
575
+ </motion.div>
576
+ ))}
577
+ </div>
578
+ </div>
579
+
580
+ {/* 分割线 */}
581
+ <div className="mx-[16px] h-px bg-black/[0.06]" />
582
+
583
+ {/* 瀑布流 Feed */}
584
+ <div className="px-[16px] pt-[12px]">
585
+ <div className="flex items-center justify-between mb-[10px]">
586
+ <span className="text-[15px] font-medium text-foreground">
587
+ {selected === "路线" ? "路线推荐" : "最新资讯"}
588
+ </span>
589
+ <button className="flex items-center gap-[2px] text-[12px] text-muted-foreground active:opacity-60">
590
+ <IconFont name="swap" size="13px" />
591
+ <span>最新</span>
592
+ </button>
593
+ </div>
594
+ <div className="flex gap-[10px]">
595
+ {/* 左列 */}
596
+ <div className="flex-1 min-w-0 flex flex-col gap-[10px]">
597
+ {filteredFeeds
598
+ .filter((_, i) => i % 2 === 0)
599
+ .map((item, i) => (
600
+ <FeedCard
601
+ key={item.id}
602
+ item={item}
603
+ index={i}
604
+ isLiked={likedPosts.has(item.id)}
605
+ onToggleLike={toggleLike}
606
+ onPostClick={setSelectedPost}
607
+ />
608
+ ))}
609
+ </div>
610
+ {/* 右列 */}
611
+ <div className="flex-1 min-w-0 flex flex-col gap-[10px]">
612
+ {filteredFeeds
613
+ .filter((_, i) => i % 2 === 1)
614
+ .map((item, i) => (
615
+ <FeedCard
616
+ key={item.id}
617
+ item={item}
618
+ index={i}
619
+ delay={0.04}
620
+ isLiked={likedPosts.has(item.id)}
621
+ onToggleLike={toggleLike}
622
+ onPostClick={setSelectedPost}
623
+ />
624
+ ))}
625
+ </div>
626
+ </div>
627
+ {/* 底部留白 */}
628
+ <div className="h-[20px]" />
629
+ </div>
630
+ </div>
631
+ </div>
632
+ </DraggablePanel>
633
+
634
+ {/* Toast */}
635
+ <div className="fixed top-[120px] left-0 right-0 z-[2000] flex justify-center pointer-events-none">
636
+ <AnimatePresence>
637
+ {toast && (
638
+ <motion.div
639
+ className="pointer-events-auto"
640
+ initial={{ y: -10, opacity: 0 }}
641
+ animate={{ y: 0, opacity: 1 }}
642
+ exit={{ y: -10, opacity: 0 }}
643
+ >
644
+ <Toast lines={[toast]} showIcon showClose onClose={() => setToast(null)} />
645
+ </motion.div>
646
+ )}
647
+ </AnimatePresence>
648
+ </div>
649
+ </div>
650
+ );
651
+ }
652
+
653
+ /* ── 瀑布流 Feed 卡片(丰富版) ── */
654
+ function FeedCard({ item, index, delay = 0, isLiked, onToggleLike, onPostClick }: {
655
+ item: FeedItem;
656
+ index: number;
657
+ delay?: number;
658
+ isLiked: boolean;
659
+ onToggleLike: (id: string, e: React.MouseEvent) => void;
660
+ onPostClick: (post: FeedItem) => void;
661
+ }) {
662
+ const isRoute = item.type === "route";
663
+ const route = isRoute ? routeRecommendations.find(r => r.id === item.routeId) : null;
664
+
665
+ const handleClick = () => {
666
+ onPostClick(item);
667
+ };
668
+
669
+ return (
670
+ <motion.div
671
+ className="rounded-[16px] overflow-hidden cursor-pointer active:scale-[0.98] transition-transform bg-card-muted"
672
+ initial={{ opacity: 0, y: 15 }}
673
+ animate={{ opacity: 1, y: 0 }}
674
+ transition={{ delay: 0.06 * index + delay, duration: 0.3 }}
675
+ onClick={handleClick}
676
+ >
677
+ {/* 图片区 */}
678
+ <div style={{ height: item.aspectH }} className="w-full bg-black/[0.04] relative">
679
+ <ImageWithFallback src={item.image} alt={item.title} className="w-full h-full object-cover" />
680
+ {isRoute && route && (
681
+ <>
682
+ <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
683
+ <div className="absolute top-[8px] left-[8px]">
684
+ <span
685
+ className="flex items-center gap-[3px] px-[7px] py-[3px] rounded-full text-[10px] font-medium text-white backdrop-blur-sm"
686
+ style={{ backgroundColor: `${route.tagColor}CC` }}
687
+ >
688
+ <IconFont name="map-navigation" size="10px" />
689
+ {route.tag}路线
690
+ </span>
691
+ </div>
692
+ <div className="absolute bottom-[8px] left-[8px] right-[8px]">
693
+ <div className="flex items-center gap-[4px] text-[10px] text-white/90">
694
+ <IconFont name="location" size="10px" />
695
+ <span>{route.stops.length}站</span>
696
+ <span className="text-white/50">·</span>
697
+ <span>{route.distance}</span>
698
+ <span className="text-white/50">·</span>
699
+ <span>{route.duration}</span>
700
+ </div>
701
+ </div>
702
+ </>
703
+ )}
704
+ {!isRoute && item.location && (
705
+ <div className="absolute top-[8px] left-[8px]">
706
+ <span className="flex items-center gap-[2px] px-[6px] py-[2px] rounded-full text-[10px] font-medium text-white bg-black/40 backdrop-blur-sm">
707
+ <IconFont name="location" size="9px" />
708
+ {item.location}
709
+ </span>
710
+ </div>
711
+ )}
712
+ </div>
713
+
714
+ {/* 信息区 */}
715
+ <div className="p-[10px]">
716
+ {/* 标题 */}
717
+ <p className="text-[13px] font-medium text-foreground leading-[18px] line-clamp-2">
718
+ {isRoute && <IconFont name="map-navigation" size="12px" className="text-accent mr-[3px] align-[-1px]" />}
719
+ {item.title}
720
+ </p>
721
+
722
+ {/* 内容摘要 */}
723
+ <p className="text-[11px] text-muted-foreground leading-[16px] line-clamp-2 mt-[4px]">
724
+ {item.content}
725
+ </p>
726
+
727
+ {/* 路线站点预览 */}
728
+ {isRoute && route && (
729
+ <div className="flex items-center gap-[4px] mt-[6px] overflow-hidden">
730
+ {route.stops.slice(0, 3).map((s, si) => (
731
+ <React.Fragment key={s.name}>
732
+ {si > 0 && <IconFont name="chevron-right" size="8px" className="text-muted-foreground/50 shrink-0" />}
733
+ <span className="text-[10px] text-muted-foreground truncate">{s.name}</span>
734
+ </React.Fragment>
735
+ ))}
736
+ {route.stops.length > 3 && <span className="text-[10px] text-muted-foreground shrink-0">+{route.stops.length - 3}</span>}
737
+ </div>
738
+ )}
739
+
740
+ {/* 标签 */}
741
+ {item.tags.length > 0 && (
742
+ <div className="flex items-center gap-[4px] mt-[6px] overflow-hidden">
743
+ {item.tags.slice(0, 2).map(tag => (
744
+ <span key={tag} className="px-[6px] py-[1px] rounded-[4px] bg-accent/8 text-[10px] text-accent font-medium shrink-0">
745
+ #{tag}
746
+ </span>
747
+ ))}
748
+ </div>
749
+ )}
750
+
751
+ {/* 作者 + 互动 */}
752
+ <div className="flex items-center justify-between mt-[8px]">
753
+ <div className="flex items-center gap-[5px] min-w-0 flex-1">
754
+ <div className="size-[18px] rounded-full overflow-hidden shrink-0 bg-black/[0.04]">
755
+ <ImageWithFallback src={item.authorAvatar} alt="" className="size-full object-cover" />
756
+ </div>
757
+ <span className="text-[11px] text-muted-foreground truncate">{item.author}</span>
758
+ {item.authorLevel && (
759
+ <span className="px-[4px] py-[1px] rounded-[3px] bg-[#FFB800]/15 text-[9px] text-[#B88600] font-medium shrink-0">
760
+ {item.authorLevel}
761
+ </span>
762
+ )}
763
+ </div>
764
+ <button
765
+ className="flex items-center gap-[2px] shrink-0 ml-[4px] active:scale-110 transition-transform"
766
+ onClick={(e) => onToggleLike(item.id, e)}
767
+ >
768
+ <IconFont
769
+ name="heart"
770
+ variant={isLiked ? "filled" : undefined}
771
+ size="13px"
772
+ className={isLiked ? "text-destructive" : "text-muted-foreground"}
773
+ />
774
+ <span className={cn("text-[11px]", isLiked ? "text-destructive" : "text-muted-foreground")}>
775
+ {formatNumber(item.likes + (isLiked ? 1 : 0))}
776
+ </span>
777
+ </button>
778
+ </div>
779
+ </div>
780
+ </motion.div>
781
+ );
782
+ }
783
+
784
+ /* ── 帖子详情视图 ── */
785
+ function PostDetailView({ post, route, isLiked, onToggleLike, onBack, onRouteClick, onToast }: {
786
+ post: FeedItem;
787
+ route: RouteItem | null;
788
+ isLiked: boolean;
789
+ onToggleLike: () => void;
790
+ onBack: () => void;
791
+ onRouteClick: (r: RouteItem) => void;
792
+ onToast: (t: string) => void;
793
+ }) {
794
+ const [panelState, setPanelState] = useState(DRAWER_STATES.MEDIUM);
795
+
796
+ return (
797
+ <div
798
+ className="relative w-full h-[100dvh] overflow-hidden bg-background"
799
+ style={{ fontFamily: "var(--font-family-sans)" }}
800
+ >
801
+ {/* 地图底图 */}
802
+ <TencentMap
803
+ className="absolute inset-0 w-full h-full"
804
+ center={{ lat: 39.929, lng: 116.417 }}
805
+ zoom={14}
806
+ />
807
+
808
+ {/* 地图上的位置标记 */}
809
+ {post.location && (
810
+ <div className="absolute inset-0 pointer-events-none z-[5]">
811
+ <motion.div
812
+ className="absolute pointer-events-auto"
813
+ style={{ top: "35%", left: "50%", transform: "translate(-50%,-100%)" }}
814
+ initial={{ opacity: 0, scale: 0.5, y: 20 }}
815
+ animate={{ opacity: 1, scale: 1, y: 0 }}
816
+ transition={{ delay: 0.2, type: "spring" }}
817
+ >
818
+ <div className="bg-accent text-white text-[11px] font-medium px-[8px] py-[3px] rounded-[8px] mb-[4px] whitespace-nowrap shadow-md">
819
+ {post.location}
820
+ </div>
821
+ <div className="size-[14px] rounded-full bg-accent shadow-[0_0_0_4px_rgba(54,123,246,0.2)] mx-auto" />
822
+ </motion.div>
823
+ </div>
824
+ )}
825
+
826
+ {/* DraggablePanel */}
827
+ <DraggablePanel
828
+ state={panelState}
829
+ onStateChange={setPanelState}
830
+ customSmallHeight={320}
831
+ customMediumHeight={500}
832
+ topToolbar={{
833
+ mode: "center-title",
834
+ title: "详情",
835
+ showBack: true,
836
+ onBack,
837
+ }}
838
+ showTopToolbarInStates={[DRAWER_STATES.SMALL, DRAWER_STATES.MEDIUM, DRAWER_STATES.LARGE]}
839
+ bottomBar={
840
+ <div className="px-[16px] pb-[16px] pt-[8px] border-t border-black/[0.04] shrink-0">
841
+ <div className="flex items-center gap-[16px]">
842
+ <div className="flex items-center gap-[20px] flex-1">
843
+ <button className="flex flex-col items-center gap-[2px] active:scale-110 transition-transform" onClick={onToggleLike}>
844
+ <IconFont name="heart" variant={isLiked ? "filled" : undefined} size="22px" className={isLiked ? "text-destructive" : "text-foreground"} />
845
+ <span className={cn("text-[10px]", isLiked ? "text-destructive" : "text-muted-foreground")}>{formatNumber(post.likes)}</span>
846
+ </button>
847
+ <button className="flex flex-col items-center gap-[2px] active:scale-110 transition-transform" onClick={() => onToast("评论功能开发中")}>
848
+ <IconFont name="chat" size="22px" className="text-foreground" />
849
+ <span className="text-[10px] text-muted-foreground">{formatNumber(post.comments)}</span>
850
+ </button>
851
+ <button className="flex flex-col items-center gap-[2px] active:scale-110 transition-transform" onClick={() => onToast("已收藏")}>
852
+ <IconFont name="star" size="22px" className="text-foreground" />
853
+ <span className="text-[10px] text-muted-foreground">收藏</span>
854
+ </button>
855
+ <button className="flex flex-col items-center gap-[2px] active:scale-110 transition-transform" onClick={() => onToast("已分享")}>
856
+ <IconFont name="share" size="22px" className="text-foreground" />
857
+ <span className="text-[10px] text-muted-foreground">{formatNumber(post.shares)}</span>
858
+ </button>
859
+ </div>
860
+ {route && (
861
+ <Btn
862
+ size="large"
863
+ variant="primary"
864
+ label="查看路线"
865
+ icon={<IconFont name="map-navigation" size="16px" />}
866
+ onClick={() => onRouteClick(route)}
867
+ className="shrink-0"
868
+ />
869
+ )}
870
+ </div>
871
+ </div>
872
+ }
873
+ >
874
+ <div className="flex flex-col h-full">
875
+ {/* 头图 */}
876
+ <div className="px-[16px] shrink-0">
877
+ <div className="rounded-[16px] overflow-hidden mb-[12px]">
878
+ <div className="relative h-[200px]">
879
+ <ImageWithFallback src={post.image} alt={post.title} className="w-full h-full object-cover" />
880
+ <div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
881
+ {post.location && (
882
+ <div className="absolute bottom-[10px] left-[10px] flex items-center gap-[4px]">
883
+ <IconFont name="location" size="12px" className="text-white/90" />
884
+ <span className="text-[12px] text-white/90 font-medium">{post.location}</span>
885
+ </div>
886
+ )}
887
+ </div>
888
+ </div>
889
+ </div>
890
+
891
+ {/* 可滚动内容 */}
892
+ <div className="flex-1 min-h-0 overflow-y-auto px-[16px]">
893
+ {/* 标题 */}
894
+ <h2 className="text-[18px] font-semibold text-foreground leading-[26px]">{post.title}</h2>
895
+
896
+ {/* 标签 */}
897
+ {post.tags.length > 0 && (
898
+ <div className="flex items-center gap-[6px] mt-[8px]">
899
+ {post.tags.map(tag => (
900
+ <span key={tag} className="px-[8px] py-[3px] rounded-[6px] bg-accent/8 text-[12px] text-accent font-medium">
901
+ #{tag}
902
+ </span>
903
+ ))}
904
+ </div>
905
+ )}
906
+
907
+ {/* 正文 */}
908
+ <p className="text-[14px] text-foreground/80 leading-[22px] mt-[12px]">
909
+ {post.content}
910
+ </p>
911
+
912
+ {/* 路线信息卡片 */}
913
+ {route && (
914
+ <motion.div
915
+ className="mt-[14px] rounded-[14px] overflow-hidden bg-card border border-black/[0.06] cursor-pointer active:scale-[0.99] transition-transform"
916
+ initial={{ opacity: 0, y: 10 }}
917
+ animate={{ opacity: 1, y: 0 }}
918
+ transition={{ delay: 0.2 }}
919
+ onClick={() => onRouteClick(route)}
920
+ >
921
+ <div className="flex items-center gap-[12px] p-[12px]">
922
+ <div className="size-[48px] rounded-[12px] overflow-hidden shrink-0">
923
+ <ImageWithFallback src={route.image} alt="" className="size-full object-cover" />
924
+ </div>
925
+ <div className="flex-1 min-w-0">
926
+ <div className="flex items-center gap-[6px]">
927
+ <span
928
+ className="flex items-center gap-[2px] px-[6px] py-[2px] rounded-full text-[10px] font-medium text-white"
929
+ style={{ backgroundColor: route.tagColor }}
930
+ >
931
+ <IconFont name="map-navigation" size="9px" />
932
+ {route.tag}
933
+ </span>
934
+ <span className="text-[13px] font-medium text-foreground truncate">{route.title}</span>
935
+ </div>
936
+ <div className="flex items-center gap-[8px] mt-[4px] text-[11px] text-muted-foreground">
937
+ <span>{route.stops.length}站</span>
938
+ <span>·</span>
939
+ <span>{route.distance}</span>
940
+ <span>·</span>
941
+ <span>{route.duration}</span>
942
+ </div>
943
+ </div>
944
+ <IconFont name="chevron-right" size="16px" className="text-muted-foreground shrink-0" />
945
+ </div>
946
+ </motion.div>
947
+ )}
948
+
949
+ {/* 作者信息 */}
950
+ <div className="flex items-center gap-[10px] mt-[16px] py-[12px] border-t border-black/[0.06]">
951
+ <div className="size-[36px] rounded-full overflow-hidden shrink-0">
952
+ <ImageWithFallback src={post.authorAvatar} alt="" className="size-full object-cover" />
953
+ </div>
954
+ <div className="flex-1 min-w-0">
955
+ <div className="flex items-center gap-[6px]">
956
+ <span className="text-[14px] font-medium text-foreground">{post.author}</span>
957
+ {post.authorLevel && (
958
+ <span className="px-[6px] py-[1px] rounded-[4px] bg-[#FFB800]/15 text-[10px] text-[#B88600] font-medium">
959
+ {post.authorLevel}
960
+ </span>
961
+ )}
962
+ </div>
963
+ <span className="text-[12px] text-muted-foreground">{post.publishTime}发布</span>
964
+ </div>
965
+ <Btn
966
+ size="small"
967
+ variant="secondary"
968
+ label="关注"
969
+ icon={<IconFont name="add" size="14px" />}
970
+ onClick={() => onToast("已关注")}
971
+ />
972
+ </div>
973
+
974
+ {/* 互动数据统计 */}
975
+ <div className="flex items-center gap-[16px] py-[12px] border-t border-black/[0.06]">
976
+ <div className="flex items-center gap-[4px]">
977
+ <IconFont name="heart" variant="filled" size="14px" className="text-destructive" />
978
+ <span className="text-[13px] text-foreground font-medium">{post.likes.toLocaleString()}</span>
979
+ <span className="text-[12px] text-muted-foreground">赞</span>
980
+ </div>
981
+ <div className="flex items-center gap-[4px]">
982
+ <IconFont name="chat" size="14px" className="text-muted-foreground" />
983
+ <span className="text-[13px] text-foreground font-medium">{post.comments.toLocaleString()}</span>
984
+ <span className="text-[12px] text-muted-foreground">评论</span>
985
+ </div>
986
+ <div className="flex items-center gap-[4px]">
987
+ <IconFont name="share" size="14px" className="text-muted-foreground" />
988
+ <span className="text-[13px] text-foreground font-medium">{post.shares.toLocaleString()}</span>
989
+ <span className="text-[12px] text-muted-foreground">分享</span>
990
+ </div>
991
+ </div>
992
+
993
+ {/* 底部留白 */}
994
+ <div className="h-[16px]" />
995
+ </div>
996
+ </div>
997
+ </DraggablePanel>
998
+
999
+ {/* Toast */}
1000
+ <div className="fixed top-[120px] left-0 right-0 z-[2000] flex justify-center pointer-events-none">
1001
+ <AnimatePresence>
1002
+ {/* reuse parent toast */}
1003
+ </AnimatePresence>
1004
+ </div>
1005
+ </div>
1006
+ );
1007
+ }
1008
+
1009
+ /* ── 工具函数 ── */
1010
+ function formatNumber(n: number): string {
1011
+ if (n >= 10000) return `${(n / 10000).toFixed(1)}w`;
1012
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
1013
+ return String(n);
1014
+ }