@lmy54321/design-system 1.1.4 → 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.
- package/.codebuddy/rules/init-project.mdc +75 -40
- package/package.json +2 -1
- package/template/index.html +18 -0
- package/template/public/manifest.json +9 -0
- package/template/src/App.tsx +55 -0
- package/template/src/index.css +4 -0
- package/template/src/main.tsx +28 -0
- package/template/src/pages/CommuteFoodPage.tsx +547 -0
- package/template/src/pages/ComponentLibrary.tsx +1049 -0
- package/template/src/pages/DeployPage.tsx +290 -0
- package/template/src/pages/DevPortal.tsx +161 -0
- package/template/src/pages/ExplorePage.tsx +1013 -0
- package/template/src/pages/MapHome.tsx +388 -0
- package/template/src/pages/MapSearchPage.tsx +529 -0
- package/template/src/pages/MePage.tsx +196 -0
- package/template/src/pages/POIRecommendPage.tsx +676 -0
- package/template/src/pages/PlanDetailPage.tsx +345 -0
- package/template/src/pages/PlanPage.tsx +602 -0
- package/template/src/pages/RouteDetailPage.tsx +276 -0
- package/template/src/pages/SearchFlowDemo.tsx +954 -0
- package/template/src/pages/SyncTool.tsx +688 -0
- package/template/src/pages/TeamTripPage.tsx +697 -0
- package/template/src/pages/TemplateDetailPage.tsx +287 -0
- package/template/tsconfig.json +23 -0
- package/template/vite.config.ts +18 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useMemo } 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 { Toast } from "@lmy54321/design-system";
|
|
7
|
+
import { ImageWithFallback } from "@lmy54321/design-system";
|
|
8
|
+
import { IconFont } from "@lmy54321/design-system";
|
|
9
|
+
|
|
10
|
+
/* ══════════════════════════════════════════
|
|
11
|
+
数据
|
|
12
|
+
══════════════════════════════════════════ */
|
|
13
|
+
|
|
14
|
+
const searchHistory = [
|
|
15
|
+
"天安门广场",
|
|
16
|
+
"三里屯太古里",
|
|
17
|
+
"北京南站",
|
|
18
|
+
"颐和园",
|
|
19
|
+
"望京SOHO",
|
|
20
|
+
"朝阳大悦城",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const hotSearches = [
|
|
24
|
+
{ rank: 1, text: "故宫博物院", hot: true },
|
|
25
|
+
{ rank: 2, text: "环球影城", hot: true },
|
|
26
|
+
{ rank: 3, text: "国贸大厦", hot: false },
|
|
27
|
+
{ rank: 4, text: "西单大悦城", hot: false },
|
|
28
|
+
{ rank: 5, text: "鸟巢体育馆", hot: false },
|
|
29
|
+
{ rank: 6, text: "中关村", hot: false },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const quickCategories = [
|
|
33
|
+
{ icon: "fork", label: "美食", image: "/images/scene/peking-duck.png" },
|
|
34
|
+
{ icon: "tea", label: "咖啡", image: "/images/scene/cafe.png" },
|
|
35
|
+
{ icon: "building", label: "酒店", image: "/images/scene/hotel.png" },
|
|
36
|
+
{ icon: "vehicle", label: "加油站", image: "/images/scene/gas-station.png" },
|
|
37
|
+
{ icon: "map-marked", label: "停车场", image: "/images/scene/parking.png" },
|
|
38
|
+
{ icon: "film", label: "电影", image: "/images/scene/cinema.png" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/* 分类卡片的倾斜角度和偏移 */
|
|
42
|
+
const categoryTransforms = [
|
|
43
|
+
{ rotate: -3, translateY: 0 },
|
|
44
|
+
{ rotate: 2, translateY: -6 },
|
|
45
|
+
{ rotate: -1.5, translateY: 4 },
|
|
46
|
+
{ rotate: 3, translateY: -8 },
|
|
47
|
+
{ rotate: -2, translateY: 2 },
|
|
48
|
+
{ rotate: 1.5, translateY: -4 },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/* 历史标签不倾斜,传统效率型排列 */
|
|
52
|
+
|
|
53
|
+
interface SuggestItem {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
address: string;
|
|
57
|
+
category: string;
|
|
58
|
+
distance: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const allSuggestions: SuggestItem[] = [
|
|
62
|
+
{ id: "1", name: "天安门广场", address: "北京市东城区东长安街", category: "景点", distance: "6.2km" },
|
|
63
|
+
{ id: "2", name: "天安门城楼", address: "北京市东城区天安门", category: "景点", distance: "6.3km" },
|
|
64
|
+
{ id: "3", name: "天坛公园", address: "北京市东城区天坛内东里7号", category: "公园", distance: "8.1km" },
|
|
65
|
+
{ id: "4", name: "天通苑", address: "北京市昌平区天通苑社区", category: "住宅区", distance: "18.5km" },
|
|
66
|
+
{ id: "5", name: "三里屯太古里", address: "北京市朝阳区三里屯路19号", category: "商场", distance: "4.5km" },
|
|
67
|
+
{ id: "6", name: "三里屯SOHO", address: "北京市朝阳区工体北路8号", category: "写字楼", distance: "4.2km" },
|
|
68
|
+
{ id: "7", name: "故宫博物院", address: "北京市东城区景山前街4号", category: "博物馆", distance: "7.0km" },
|
|
69
|
+
{ id: "8", name: "颐和园", address: "北京市海淀区新建宫门路19号", category: "公园", distance: "14.2km" },
|
|
70
|
+
{ id: "9", name: "北京南站", address: "北京市丰台区永外大街车站路12号", category: "火车站", distance: "8.5km" },
|
|
71
|
+
{ id: "10", name: "北京西站", address: "北京市丰台区莲花池东路118号", category: "火车站", distance: "10.3km" },
|
|
72
|
+
{ id: "11", name: "望京SOHO", address: "北京市朝阳区望京街10号", category: "写字楼", distance: "2.1km" },
|
|
73
|
+
{ id: "12", name: "国贸商城", address: "北京市朝阳区建国门外大街1号", category: "商场", distance: "5.8km" },
|
|
74
|
+
{ id: "13", name: "朝阳大悦城", address: "北京市朝阳区朝阳北路101号", category: "商场", distance: "3.2km" },
|
|
75
|
+
{ id: "14", name: "中关村软件园", address: "北京市海淀区东北旺西路8号", category: "科技园", distance: "15.2km" },
|
|
76
|
+
{ id: "15", name: "环球影城", address: "北京市通州区京哈高速与东六环路交叉口", category: "主题公园", distance: "25.0km" },
|
|
77
|
+
{ id: "16", name: "西单大悦城", address: "北京市西城区西单北大街131号", category: "商场", distance: "8.8km" },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/* ══════════════════════════════════════════
|
|
81
|
+
高亮文字工具
|
|
82
|
+
══════════════════════════════════════════ */
|
|
83
|
+
|
|
84
|
+
function HighlightText({ text, query }: { text: string; query: string }) {
|
|
85
|
+
if (!query) return <>{text}</>;
|
|
86
|
+
const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"));
|
|
87
|
+
return (
|
|
88
|
+
<>
|
|
89
|
+
{parts.map((part, i) =>
|
|
90
|
+
part.toLowerCase() === query.toLowerCase() ? (
|
|
91
|
+
<span key={i} className="text-accent font-medium">{part}</span>
|
|
92
|
+
) : (
|
|
93
|
+
<span key={i}>{part}</span>
|
|
94
|
+
)
|
|
95
|
+
)}
|
|
96
|
+
</>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ══════════════════════════════════════════
|
|
101
|
+
快捷分类 — 倾斜错落图片卡片
|
|
102
|
+
══════════════════════════════════════════ */
|
|
103
|
+
|
|
104
|
+
function QuickCategoryGrid({
|
|
105
|
+
onSelect,
|
|
106
|
+
}: {
|
|
107
|
+
onSelect: (label: string) => void;
|
|
108
|
+
}) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="px-[var(--spacing-base)] pt-[var(--spacing-xs)] pb-[var(--spacing-sm)]">
|
|
111
|
+
<div className="flex items-center gap-[6px] mb-[var(--spacing-sm)] px-[var(--spacing-xxs)]">
|
|
112
|
+
<IconFont name="star" size="13px" className="text-accent" />
|
|
113
|
+
<span className="text-[13px] font-medium text-foreground/70 leading-[18px]">快捷探索</span>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="flex gap-[var(--spacing-xs)] overflow-x-auto pb-[var(--spacing-xs)]" style={{ scrollbarWidth: "none" }}>
|
|
116
|
+
{quickCategories.map((cat, index) => {
|
|
117
|
+
const t = categoryTransforms[index];
|
|
118
|
+
return (
|
|
119
|
+
<motion.button
|
|
120
|
+
key={cat.label}
|
|
121
|
+
initial={{ opacity: 0, y: 20, rotate: 0 }}
|
|
122
|
+
animate={{ opacity: 1, y: 0, rotate: t.rotate }}
|
|
123
|
+
transition={{ delay: index * 0.05, type: "spring", stiffness: 300, damping: 20 }}
|
|
124
|
+
onClick={() => onSelect(cat.label)}
|
|
125
|
+
className={cn(
|
|
126
|
+
"flex flex-col items-center shrink-0 w-[88px] cursor-pointer group",
|
|
127
|
+
"active:scale-95 transition-transform"
|
|
128
|
+
)}
|
|
129
|
+
style={{ transform: `rotate(${t.rotate}deg) translateY(${t.translateY}px)` }}
|
|
130
|
+
>
|
|
131
|
+
{/* 图片卡 */}
|
|
132
|
+
<div
|
|
133
|
+
className={cn(
|
|
134
|
+
"w-[80px] h-[64px] rounded-[16px] overflow-hidden mb-[var(--spacing-xs)]",
|
|
135
|
+
"bg-card border border-card-border shadow-[var(--elevation-sm)]",
|
|
136
|
+
"group-active:shadow-none transition-shadow"
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
<ImageWithFallback
|
|
140
|
+
src={cat.image}
|
|
141
|
+
alt={cat.label}
|
|
142
|
+
className="w-full h-full object-cover"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
{/* icon + 文字 */}
|
|
146
|
+
<div className="flex items-center gap-[2px]">
|
|
147
|
+
<IconFont name={cat.icon} size="14px" className="text-foreground" />
|
|
148
|
+
<span className="text-[12px] text-foreground font-medium leading-[16px] whitespace-nowrap">
|
|
149
|
+
{cat.label}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
</motion.button>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ══════════════════════════════════════════
|
|
161
|
+
搜索历史 — 散落标签风格
|
|
162
|
+
══════════════════════════════════════════ */
|
|
163
|
+
|
|
164
|
+
function HistorySection({
|
|
165
|
+
history,
|
|
166
|
+
onSelect,
|
|
167
|
+
onRemove,
|
|
168
|
+
onClear,
|
|
169
|
+
}: {
|
|
170
|
+
history: string[];
|
|
171
|
+
onSelect: (text: string) => void;
|
|
172
|
+
onRemove: (text: string) => void;
|
|
173
|
+
onClear: () => void;
|
|
174
|
+
}) {
|
|
175
|
+
if (history.length === 0) return null;
|
|
176
|
+
return (
|
|
177
|
+
<div className="px-[var(--spacing-base)] pb-[var(--spacing-md)]">
|
|
178
|
+
<div className="flex items-center justify-between mb-[var(--spacing-sm)]">
|
|
179
|
+
<div className="flex items-center gap-[var(--spacing-xxs)]">
|
|
180
|
+
<IconFont name="time" size="14px" className="text-muted-foreground" />
|
|
181
|
+
<span className="text-[13px] font-medium text-foreground leading-[18px]">最近搜索</span>
|
|
182
|
+
</div>
|
|
183
|
+
<button
|
|
184
|
+
onClick={onClear}
|
|
185
|
+
className="text-[12px] text-muted-foreground active:opacity-60 transition-opacity"
|
|
186
|
+
>
|
|
187
|
+
清空
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div className="flex flex-wrap gap-[var(--spacing-xs)] items-center">
|
|
192
|
+
{history.map((text, index) => (
|
|
193
|
+
<motion.button
|
|
194
|
+
key={text}
|
|
195
|
+
initial={{ opacity: 0, scale: 0.92 }}
|
|
196
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
197
|
+
transition={{ delay: index * 0.03, duration: 0.2 }}
|
|
198
|
+
onClick={() => onSelect(text)}
|
|
199
|
+
className={cn(
|
|
200
|
+
"h-[34px] px-[var(--spacing-sm)] flex items-center gap-[6px]",
|
|
201
|
+
"bg-card rounded-[var(--radius-button)]",
|
|
202
|
+
"border border-card-border shadow-[var(--elevation-sm)]",
|
|
203
|
+
"text-[13px] text-foreground",
|
|
204
|
+
"active:bg-muted transition-colors",
|
|
205
|
+
"hover:shadow-md"
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
{text}
|
|
209
|
+
<span
|
|
210
|
+
onClick={(e) => {
|
|
211
|
+
e.stopPropagation();
|
|
212
|
+
onRemove(text);
|
|
213
|
+
}}
|
|
214
|
+
className="text-muted-foreground/40 hover:text-muted-foreground"
|
|
215
|
+
>
|
|
216
|
+
<IconFont name="close" size="11px" />
|
|
217
|
+
</span>
|
|
218
|
+
</motion.button>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ══════════════════════════════════════════
|
|
226
|
+
热门搜索 — 卡片式错落布局
|
|
227
|
+
══════════════════════════════════════════ */
|
|
228
|
+
|
|
229
|
+
/* 热门搜索不使用倾斜,保持规整高效 */
|
|
230
|
+
|
|
231
|
+
function HotSearchSection({
|
|
232
|
+
onSelect,
|
|
233
|
+
}: {
|
|
234
|
+
onSelect: (text: string) => void;
|
|
235
|
+
}) {
|
|
236
|
+
/* 拆成两列 */
|
|
237
|
+
const col1 = hotSearches.filter((_, i) => i % 2 === 0);
|
|
238
|
+
const col2 = hotSearches.filter((_, i) => i % 2 === 1);
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div className="px-[var(--spacing-base)] pt-[var(--spacing-md)] pb-[var(--spacing-lg)]">
|
|
242
|
+
<div className="flex items-center gap-[var(--spacing-xxs)] mb-[var(--spacing-sm)]">
|
|
243
|
+
<IconFont name="trending-up" size="14px" className="text-destructive" />
|
|
244
|
+
<span className="text-[13px] font-medium text-foreground leading-[18px]">热门搜索</span>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div className="flex gap-[var(--spacing-sm)]">
|
|
248
|
+
{/* 左列 */}
|
|
249
|
+
<div className="flex-1 flex flex-col gap-[var(--spacing-xs)]">
|
|
250
|
+
{col1.map((item) => (
|
|
251
|
+
<HotCard key={item.text} item={item} onSelect={onSelect} />
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
{/* 右列 — 与左列对齐,不做偏移 */}
|
|
255
|
+
<div className="flex-1 flex flex-col gap-[var(--spacing-xs)]">
|
|
256
|
+
{col2.map((item) => (
|
|
257
|
+
<HotCard key={item.text} item={item} onSelect={onSelect} />
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function HotCard({
|
|
266
|
+
item,
|
|
267
|
+
onSelect,
|
|
268
|
+
}: {
|
|
269
|
+
item: { rank: number; text: string; hot: boolean };
|
|
270
|
+
onSelect: (text: string) => void;
|
|
271
|
+
}) {
|
|
272
|
+
return (
|
|
273
|
+
<motion.button
|
|
274
|
+
initial={{ opacity: 0, y: 10 }}
|
|
275
|
+
animate={{ opacity: 1, y: 0 }}
|
|
276
|
+
transition={{
|
|
277
|
+
delay: item.rank * 0.04,
|
|
278
|
+
duration: 0.25,
|
|
279
|
+
}}
|
|
280
|
+
onClick={() => onSelect(item.text)}
|
|
281
|
+
className={cn(
|
|
282
|
+
"flex items-center gap-[var(--spacing-xs)] p-[var(--spacing-sm)] text-left",
|
|
283
|
+
"bg-card rounded-[20px]",
|
|
284
|
+
"border border-card-border shadow-[var(--elevation-sm)]",
|
|
285
|
+
"active:scale-[0.97] transition-transform",
|
|
286
|
+
"hover:shadow-md"
|
|
287
|
+
)}
|
|
288
|
+
>
|
|
289
|
+
{/* 排名圆圈 */}
|
|
290
|
+
<div
|
|
291
|
+
className={cn(
|
|
292
|
+
"size-[26px] rounded-full flex items-center justify-center shrink-0 text-[12px] font-bold",
|
|
293
|
+
item.rank <= 3 ? "bg-destructive/12 text-destructive" : "bg-muted text-muted-foreground"
|
|
294
|
+
)}
|
|
295
|
+
>
|
|
296
|
+
{item.rank}
|
|
297
|
+
</div>
|
|
298
|
+
{/* 文字 */}
|
|
299
|
+
<span className="text-[14px] text-foreground leading-[20px] flex-1 truncate font-medium">
|
|
300
|
+
{item.text}
|
|
301
|
+
</span>
|
|
302
|
+
{/* 热标签 */}
|
|
303
|
+
{item.hot && (
|
|
304
|
+
<Tag
|
|
305
|
+
label="热"
|
|
306
|
+
size="xs"
|
|
307
|
+
className="bg-destructive/10 text-destructive border-transparent shrink-0"
|
|
308
|
+
/>
|
|
309
|
+
)}
|
|
310
|
+
</motion.button>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* ══════════════════════════════════════════
|
|
315
|
+
搜索建议列表
|
|
316
|
+
══════════════════════════════════════════ */
|
|
317
|
+
|
|
318
|
+
function SuggestionList({
|
|
319
|
+
suggestions,
|
|
320
|
+
query,
|
|
321
|
+
onSelect,
|
|
322
|
+
}: {
|
|
323
|
+
suggestions: SuggestItem[];
|
|
324
|
+
query: string;
|
|
325
|
+
onSelect: (item: SuggestItem) => void;
|
|
326
|
+
}) {
|
|
327
|
+
if (suggestions.length === 0) {
|
|
328
|
+
return (
|
|
329
|
+
<div className="flex flex-col items-center justify-center py-[var(--spacing-3xl)]">
|
|
330
|
+
<div className="size-[56px] rounded-full bg-card-muted flex items-center justify-center text-muted-foreground mb-[var(--spacing-sm)]">
|
|
331
|
+
<IconFont name="search" size="24px" />
|
|
332
|
+
</div>
|
|
333
|
+
<p className="text-[14px] text-muted-foreground">
|
|
334
|
+
{`\u672a\u627e\u5230\u300c${query}\u300d\u76f8\u5173\u7ed3\u679c`}
|
|
335
|
+
</p>
|
|
336
|
+
<p className="text-[12px] text-muted-foreground/60 mt-[var(--spacing-xxs)]">
|
|
337
|
+
试试其他关键词
|
|
338
|
+
</p>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<div className="flex flex-col px-[var(--spacing-base)]">
|
|
345
|
+
{suggestions.map((item, index) => (
|
|
346
|
+
<motion.button
|
|
347
|
+
key={item.id}
|
|
348
|
+
initial={{ opacity: 0, x: -10 }}
|
|
349
|
+
animate={{ opacity: 1, x: 0 }}
|
|
350
|
+
transition={{ delay: index * 0.03 }}
|
|
351
|
+
onClick={() => onSelect(item)}
|
|
352
|
+
className={cn(
|
|
353
|
+
"flex items-center gap-[var(--spacing-sm)] py-[var(--spacing-sm)] text-left",
|
|
354
|
+
"active:bg-muted/40 -mx-[var(--spacing-xxs)] px-[var(--spacing-xxs)] rounded-[12px] transition-colors",
|
|
355
|
+
index < suggestions.length - 1 && "border-b border-border/50"
|
|
356
|
+
)}
|
|
357
|
+
>
|
|
358
|
+
<div className="size-[36px] rounded-full bg-card-muted flex items-center justify-center text-muted-foreground shrink-0">
|
|
359
|
+
<IconFont name="location" size="18px" />
|
|
360
|
+
</div>
|
|
361
|
+
<div className="flex-1 min-w-0">
|
|
362
|
+
<div className="flex items-center gap-[var(--spacing-xs)]">
|
|
363
|
+
<span className="text-[15px] font-medium text-foreground leading-[22px] truncate">
|
|
364
|
+
<HighlightText text={item.name} query={query} />
|
|
365
|
+
</span>
|
|
366
|
+
<Tag
|
|
367
|
+
label={item.category}
|
|
368
|
+
size="xs"
|
|
369
|
+
className="bg-card-muted text-muted-foreground border-transparent shrink-0"
|
|
370
|
+
/>
|
|
371
|
+
</div>
|
|
372
|
+
<span className="text-[12px] text-muted-foreground leading-[16px] truncate block mt-[2px]">
|
|
373
|
+
<HighlightText text={item.address} query={query} />
|
|
374
|
+
</span>
|
|
375
|
+
</div>
|
|
376
|
+
<span className="text-[12px] text-accent font-medium shrink-0">{item.distance}</span>
|
|
377
|
+
<div className="text-muted-foreground/40 shrink-0">
|
|
378
|
+
<IconFont name="arrow-up" size="14px" style={{ transform: "rotate(-45deg)" }} />
|
|
379
|
+
</div>
|
|
380
|
+
</motion.button>
|
|
381
|
+
))}
|
|
382
|
+
</div>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* ══════════════════════════════════════════
|
|
387
|
+
主页面
|
|
388
|
+
══════════════════════════════════════════ */
|
|
389
|
+
|
|
390
|
+
export function MapSearchPage() {
|
|
391
|
+
const [searchValue, setSearchValue] = useState("");
|
|
392
|
+
const [toastText, setToastText] = useState<string | null>(null);
|
|
393
|
+
const [history, setHistory] = useState(searchHistory);
|
|
394
|
+
|
|
395
|
+
const showToast = useCallback((text: string) => {
|
|
396
|
+
setToastText(text);
|
|
397
|
+
}, []);
|
|
398
|
+
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
if (!toastText) return;
|
|
401
|
+
const timer = setTimeout(() => setToastText(null), 2200);
|
|
402
|
+
return () => clearTimeout(timer);
|
|
403
|
+
}, [toastText]);
|
|
404
|
+
|
|
405
|
+
const suggestions = useMemo(() => {
|
|
406
|
+
if (!searchValue.trim()) return [];
|
|
407
|
+
const q = searchValue.trim().toLowerCase();
|
|
408
|
+
return allSuggestions.filter(
|
|
409
|
+
(s) => s.name.toLowerCase().includes(q) || s.address.toLowerCase().includes(q)
|
|
410
|
+
);
|
|
411
|
+
}, [searchValue]);
|
|
412
|
+
|
|
413
|
+
const hasInput = searchValue.trim().length > 0;
|
|
414
|
+
|
|
415
|
+
const handleSelectSuggestion = (item: SuggestItem) => {
|
|
416
|
+
showToast(`\u6b63\u5728\u5bfc\u822a\u81f3${item.name}...`);
|
|
417
|
+
setSearchValue(item.name);
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const handleSelectHistory = (text: string) => {
|
|
421
|
+
setSearchValue(text);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const handleClearHistory = () => {
|
|
425
|
+
setHistory([]);
|
|
426
|
+
showToast("\u641c\u7d22\u5386\u53f2\u5df2\u6e05\u7a7a");
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const handleRemoveHistoryItem = (text: string) => {
|
|
430
|
+
setHistory((prev) => prev.filter((h) => h !== text));
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<div
|
|
435
|
+
className="relative w-full h-[100dvh] overflow-hidden flex flex-col"
|
|
436
|
+
style={{ fontFamily: "var(--font-family-sans)" }}
|
|
437
|
+
>
|
|
438
|
+
{/* ── 渐变背景 ── */}
|
|
439
|
+
<div className="absolute inset-0 bg-gradient-to-b from-[#EEF2F9] via-[#F4F6FB] to-background" />
|
|
440
|
+
{/* 装饰光斑 */}
|
|
441
|
+
<div className="absolute top-[-40px] right-[-50px] w-[180px] h-[180px] bg-accent/6 rounded-full blur-[80px]" />
|
|
442
|
+
<div className="absolute top-[200px] left-[-70px] w-[160px] h-[160px] bg-destructive/4 rounded-full blur-[70px]" />
|
|
443
|
+
<div className="absolute bottom-[100px] right-[-30px] w-[140px] h-[140px] bg-[#F0D6A0]/10 rounded-full blur-[60px]" />
|
|
444
|
+
|
|
445
|
+
{/* ── 主内容 ── */}
|
|
446
|
+
<div className="relative z-10 flex-1 flex flex-col overflow-hidden">
|
|
447
|
+
|
|
448
|
+
{/* ── 顶部搜索栏 ── */}
|
|
449
|
+
<div className="relative z-50 pt-[56px] px-[var(--spacing-base)] pb-[var(--spacing-xs)]">
|
|
450
|
+
<SearchBox
|
|
451
|
+
value={searchValue}
|
|
452
|
+
onChange={setSearchValue}
|
|
453
|
+
variant="card"
|
|
454
|
+
placeholder="\u641c\u7d22\u5730\u70b9\u3001\u516c\u4ea4\u3001\u5730\u94c1"
|
|
455
|
+
onBack={() => setSearchValue("")}
|
|
456
|
+
onSearch={() => {
|
|
457
|
+
if (searchValue.trim()) showToast(`\u6b63\u5728\u641c\u7d22\u300c${searchValue}\u300d...`);
|
|
458
|
+
}}
|
|
459
|
+
onMic={() => showToast("\u8bed\u97f3\u8bc6\u522b\u4e2d...")}
|
|
460
|
+
onClear={() => setSearchValue("")}
|
|
461
|
+
className="bg-input-background"
|
|
462
|
+
/>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
{/* ── 内容区(滚动) ── */}
|
|
466
|
+
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: "none" }}>
|
|
467
|
+
<AnimatePresence mode="wait">
|
|
468
|
+
{hasInput ? (
|
|
469
|
+
<motion.div
|
|
470
|
+
key="suggestions"
|
|
471
|
+
initial={{ opacity: 0, y: 12 }}
|
|
472
|
+
animate={{ opacity: 1, y: 0 }}
|
|
473
|
+
exit={{ opacity: 0, y: -8 }}
|
|
474
|
+
transition={{ duration: 0.2 }}
|
|
475
|
+
>
|
|
476
|
+
<SuggestionList
|
|
477
|
+
suggestions={suggestions}
|
|
478
|
+
query={searchValue.trim()}
|
|
479
|
+
onSelect={handleSelectSuggestion}
|
|
480
|
+
/>
|
|
481
|
+
</motion.div>
|
|
482
|
+
) : (
|
|
483
|
+
<motion.div
|
|
484
|
+
key="default"
|
|
485
|
+
initial={{ opacity: 0, y: 12 }}
|
|
486
|
+
animate={{ opacity: 1, y: 0 }}
|
|
487
|
+
exit={{ opacity: 0, y: -8 }}
|
|
488
|
+
transition={{ duration: 0.2 }}
|
|
489
|
+
>
|
|
490
|
+
{/* 快捷分类 — 倾斜图片卡片 */}
|
|
491
|
+
<QuickCategoryGrid onSelect={handleSelectHistory} />
|
|
492
|
+
|
|
493
|
+
{/* 搜索历史 — 散落标签 */}
|
|
494
|
+
<HistorySection
|
|
495
|
+
history={history}
|
|
496
|
+
onSelect={handleSelectHistory}
|
|
497
|
+
onRemove={handleRemoveHistoryItem}
|
|
498
|
+
onClear={handleClearHistory}
|
|
499
|
+
/>
|
|
500
|
+
|
|
501
|
+
{/* 分割线 */}
|
|
502
|
+
<div className="mx-[var(--spacing-lg)] h-px bg-border/30" />
|
|
503
|
+
|
|
504
|
+
{/* 热门搜索 — 错落双列卡片 */}
|
|
505
|
+
<HotSearchSection onSelect={handleSelectHistory} />
|
|
506
|
+
</motion.div>
|
|
507
|
+
)}
|
|
508
|
+
</AnimatePresence>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
{/* ── Toast ── */}
|
|
513
|
+
<div className="fixed top-[120px] left-0 right-0 z-[200] flex justify-center pointer-events-none">
|
|
514
|
+
<AnimatePresence>
|
|
515
|
+
{toastText && (
|
|
516
|
+
<div className="pointer-events-auto">
|
|
517
|
+
<Toast
|
|
518
|
+
lines={[toastText]}
|
|
519
|
+
showIcon={true}
|
|
520
|
+
showClose={true}
|
|
521
|
+
onClose={() => setToastText(null)}
|
|
522
|
+
/>
|
|
523
|
+
</div>
|
|
524
|
+
)}
|
|
525
|
+
</AnimatePresence>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
);
|
|
529
|
+
}
|