@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,676 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef } 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 { Btn } 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
|
+
import { TencentMap } from "@lmy54321/design-system";
|
|
10
|
+
|
|
11
|
+
/* ══════════════════════════════════════════
|
|
12
|
+
推荐卡片数据
|
|
13
|
+
══════════════════════════════════════════ */
|
|
14
|
+
|
|
15
|
+
interface POICard {
|
|
16
|
+
id: string;
|
|
17
|
+
images: string[];
|
|
18
|
+
title: string;
|
|
19
|
+
tags: { label: string; icon?: "flag" | "pin"; accent?: boolean }[];
|
|
20
|
+
aiPrompt: string;
|
|
21
|
+
distance: string;
|
|
22
|
+
type: "route" | "single" | "topic" | "explore";
|
|
23
|
+
badge?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const poiCards: POICard[] = [
|
|
27
|
+
{
|
|
28
|
+
id: "1",
|
|
29
|
+
images: [
|
|
30
|
+
"/images/scene/relax.png",
|
|
31
|
+
],
|
|
32
|
+
title: "\u5076\u5c14\u653e\u7a7a\uff0c\u5728\u5306\u5fd9\u7684\u57ce\u5e02\u4eab\u53d7\u9633\u5149",
|
|
33
|
+
tags: [
|
|
34
|
+
{ label: "\u6674\u5929\u9002\u5b9c", icon: "pin" },
|
|
35
|
+
{ label: "\u65b0\u5f00\u4e1a" },
|
|
36
|
+
{ label: "\u6709\u5145\u7535\u6869" },
|
|
37
|
+
],
|
|
38
|
+
aiPrompt: "\u201c\u6211\u5bb6\u9644\u8fd1\u6709\u6ca1\u6709\u7c7b\u4f3c\u7684\u5e97\u201d",
|
|
39
|
+
distance: "1.1km",
|
|
40
|
+
type: "route",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "2",
|
|
44
|
+
images: [
|
|
45
|
+
"/images/scene/poi-relax.png",
|
|
46
|
+
"/images/scene/cafe.png",
|
|
47
|
+
],
|
|
48
|
+
title: "\u5076\u5c14\u653e\u7a7a\uff0c\u5728\u5306\u5fd9\u7684\u57ce\u5e02\u4eab\u53d7\u9633\u5149",
|
|
49
|
+
tags: [
|
|
50
|
+
{ label: "\u7cbe\u9009\u6f14\u51fa", icon: "flag", accent: true },
|
|
51
|
+
{ label: "\u677e\u5f1b\u611f" },
|
|
52
|
+
],
|
|
53
|
+
aiPrompt: "\u201c\u4e0d\u8981\u6cd5\u9910\u5385\uff0c\u6362\u4e00\u4e2a\u5403\u591c\u5bb5\u7684\u201d",
|
|
54
|
+
distance: "1.1km",
|
|
55
|
+
type: "single",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "3",
|
|
59
|
+
images: [
|
|
60
|
+
"/images/scene/friends-outing.png",
|
|
61
|
+
],
|
|
62
|
+
title: "\u5076\u5c14\u653e\u7a7a\uff0c\u5728\u5306\u5fd9\u7684\u57ce\u5e02\u4eab\u53d7\u9633\u5149",
|
|
63
|
+
tags: [
|
|
64
|
+
{ label: "\u7cbe\u9009\u6f14\u51fa", icon: "flag", accent: true },
|
|
65
|
+
{ label: "\u677e\u5f1b\u611f" },
|
|
66
|
+
],
|
|
67
|
+
aiPrompt: "\u201c\u4e0d\u8981\u6cd5\u9910\u5385\uff0c\u6362\u4e00\u4e2a\u5403\u591c\u5bb5\u7684\u201d",
|
|
68
|
+
distance: "1.1km",
|
|
69
|
+
type: "topic",
|
|
70
|
+
badge: "\u5546\u5708\u5185",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "4",
|
|
74
|
+
images: [
|
|
75
|
+
"/images/scene/poi-retro.png",
|
|
76
|
+
"/images/scene/restaurant.png",
|
|
77
|
+
"/images/scene/cafe.png",
|
|
78
|
+
],
|
|
79
|
+
title: "\u590d\u53e4\u56de\u6f6e\uff1a\u63a2\u5e97\u5357\u5934\u53e4\u57ce5\u5bb6\u6000\u65e7\u98ce\u5c0f\u5e97",
|
|
80
|
+
tags: [
|
|
81
|
+
{ label: "\u5546\u5708\u5185", icon: "flag", accent: true },
|
|
82
|
+
{ label: "\u5ba0\u7269\u53cb\u597d" },
|
|
83
|
+
{ label: "\u4e3b\u9898\u73a9\u6cd5" },
|
|
84
|
+
],
|
|
85
|
+
aiPrompt: "\u201c\u6211\u5bb6\u9644\u8fd1\u6709\u6ca1\u6709\u7c7b\u4f3c\u7684\u5e97\u201d",
|
|
86
|
+
distance: "1.1km",
|
|
87
|
+
type: "explore",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "5",
|
|
91
|
+
images: [
|
|
92
|
+
"/images/feed/feed-park.png",
|
|
93
|
+
],
|
|
94
|
+
title: "\u5076\u5c14\u653e\u7a7a\uff0c\u5728\u5306\u5fd9\u7684\u57ce\u5e02\u4eab\u53d7\u9633\u5149",
|
|
95
|
+
tags: [
|
|
96
|
+
{ label: "\u6674\u5929\u9002\u5b9c", icon: "pin" },
|
|
97
|
+
{ label: "\u6c1b\u56f4\u611f" },
|
|
98
|
+
{ label: "\u597d\u505c\u8f66" },
|
|
99
|
+
],
|
|
100
|
+
aiPrompt: "\u201c\u6709\u6ca1\u6709\u5b89\u9759\u4e00\u70b9\u7684\u9152\u5427\u201d",
|
|
101
|
+
distance: "2.3km",
|
|
102
|
+
type: "route",
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/* ══════════════════════════════════════════
|
|
107
|
+
图片布局子组件(按卡片类型区分)
|
|
108
|
+
— 带倾斜、压盖、错落的活泼布局
|
|
109
|
+
══════════════════════════════════════════ */
|
|
110
|
+
|
|
111
|
+
function RouteImageLayout({ card }: { card: POICard }) {
|
|
112
|
+
return (
|
|
113
|
+
<div className="relative w-full h-[200px] overflow-hidden">
|
|
114
|
+
{/* 地图背景 */}
|
|
115
|
+
<TencentMap className="absolute inset-0 w-full h-full" center={{ lat: 39.909, lng: 116.397 }} />
|
|
116
|
+
{/* 倾斜的照片叠在地图上 */}
|
|
117
|
+
<div
|
|
118
|
+
className="absolute bottom-[16px] right-[16px] w-[110px] h-[80px] rounded-[12px] overflow-hidden shadow-lg border-[2.5px] border-white z-10"
|
|
119
|
+
style={{ transform: "rotate(4deg)" }}
|
|
120
|
+
>
|
|
121
|
+
<ImageWithFallback
|
|
122
|
+
src={card.images[0]}
|
|
123
|
+
alt={card.title}
|
|
124
|
+
className="w-full h-full object-cover"
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
{/* 距离标签 */}
|
|
128
|
+
<div className="absolute top-[var(--spacing-sm)] left-[var(--spacing-sm)] z-10 flex items-center gap-[3px] bg-white/90 backdrop-blur-sm rounded-[8px] px-[8px] py-[4px] shadow-sm">
|
|
129
|
+
<IconFont name="location" size="10px" className="text-accent" />
|
|
130
|
+
<span className="text-[11px] text-foreground/70 leading-[14px]">距你{card.distance}</span>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function SingleImageLayout({ card }: { card: POICard }) {
|
|
137
|
+
return (
|
|
138
|
+
<div className="relative w-full h-[200px] overflow-hidden bg-card-muted">
|
|
139
|
+
{/* 底层大图 — 微微倾斜 */}
|
|
140
|
+
<div
|
|
141
|
+
className="absolute top-[10px] left-[12px] w-[180px] h-[160px] rounded-[16px] overflow-hidden shadow-md"
|
|
142
|
+
style={{ transform: "rotate(-3deg)" }}
|
|
143
|
+
>
|
|
144
|
+
<ImageWithFallback
|
|
145
|
+
src={card.images[0]}
|
|
146
|
+
alt={card.title}
|
|
147
|
+
className="w-full h-full object-cover"
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
{/* 上层小图 — 反方向倾斜,压盖在大图右下 */}
|
|
151
|
+
{card.images[1] && (
|
|
152
|
+
<div
|
|
153
|
+
className="absolute bottom-[8px] right-[12px] w-[140px] h-[120px] rounded-[14px] overflow-hidden shadow-lg border-[2.5px] border-white"
|
|
154
|
+
style={{ transform: "rotate(3.5deg)", zIndex: 2 }}
|
|
155
|
+
>
|
|
156
|
+
<ImageWithFallback
|
|
157
|
+
src={card.images[1]}
|
|
158
|
+
alt={card.title}
|
|
159
|
+
className="w-full h-full object-cover"
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
{/* 距离标签 */}
|
|
164
|
+
<div className="absolute top-[var(--spacing-sm)] left-[var(--spacing-sm)] flex items-center gap-[3px] bg-black/40 backdrop-blur-sm rounded-[8px] px-[8px] py-[4px]" style={{ zIndex: 3 }}>
|
|
165
|
+
<IconFont name="location" size="10px" className="text-white/90" />
|
|
166
|
+
<span className="text-[11px] text-white/90 leading-[14px]">距你{card.distance}</span>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function TopicImageLayout({ card }: { card: POICard }) {
|
|
173
|
+
return (
|
|
174
|
+
<div className="relative w-full h-[200px] overflow-hidden">
|
|
175
|
+
{/* 主图铺满 */}
|
|
176
|
+
<ImageWithFallback
|
|
177
|
+
src={card.images[0]}
|
|
178
|
+
alt={card.title}
|
|
179
|
+
className="w-full h-full object-cover"
|
|
180
|
+
/>
|
|
181
|
+
<div className="absolute inset-x-0 bottom-0 h-[80px] bg-gradient-to-t from-black/40 to-transparent" />
|
|
182
|
+
{/* 距离标签 */}
|
|
183
|
+
<div className="absolute top-[var(--spacing-sm)] left-[var(--spacing-sm)] flex items-center gap-[3px] bg-black/40 backdrop-blur-sm rounded-[8px] px-[8px] py-[4px]">
|
|
184
|
+
<IconFont name="location" size="10px" className="text-white/90" />
|
|
185
|
+
<span className="text-[11px] text-white/90 leading-[14px]">距你{card.distance}</span>
|
|
186
|
+
</div>
|
|
187
|
+
{/* 右上角徽章 */}
|
|
188
|
+
{card.badge && (
|
|
189
|
+
<div className="absolute top-[var(--spacing-sm)] right-[var(--spacing-sm)] flex items-center gap-[3px] bg-accent/90 backdrop-blur-sm rounded-[8px] px-[8px] py-[4px]">
|
|
190
|
+
<IconFont name="flag" size="10px" className="text-white" />
|
|
191
|
+
<span className="text-[11px] text-white font-medium leading-[14px]">{card.badge}</span>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
{/* 左下角场景标签 */}
|
|
195
|
+
{card.tags[0] && (
|
|
196
|
+
<div className="absolute bottom-[var(--spacing-sm)] left-[var(--spacing-sm)] flex items-center gap-[3px] bg-white/85 backdrop-blur-sm rounded-[8px] px-[8px] py-[4px] shadow-sm">
|
|
197
|
+
<IconFont name="location" size="10px" className="text-accent" />
|
|
198
|
+
<span className="text-[11px] text-foreground/80 font-medium leading-[14px]">{card.tags[0].label}</span>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ExploreImageLayout({ card }: { card: POICard }) {
|
|
206
|
+
return (
|
|
207
|
+
<div className="relative w-full h-[200px] overflow-hidden bg-card-muted">
|
|
208
|
+
{/* 主图(左侧偏大,微倾) */}
|
|
209
|
+
<div
|
|
210
|
+
className="absolute top-[10px] left-[8px] w-[160px] h-[170px] rounded-[14px] overflow-hidden shadow-md"
|
|
211
|
+
style={{ transform: "rotate(-2.5deg)", zIndex: 1 }}
|
|
212
|
+
>
|
|
213
|
+
<ImageWithFallback
|
|
214
|
+
src={card.images[0]}
|
|
215
|
+
alt={card.title}
|
|
216
|
+
className="w-full h-full object-cover"
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
{/* 右上小图(正向倾斜,压在主图上方) */}
|
|
220
|
+
<div
|
|
221
|
+
className="absolute top-[6px] right-[10px] w-[120px] h-[90px] rounded-[12px] overflow-hidden shadow-lg border-[2px] border-white"
|
|
222
|
+
style={{ transform: "rotate(4deg)", zIndex: 2 }}
|
|
223
|
+
>
|
|
224
|
+
<ImageWithFallback
|
|
225
|
+
src={card.images[1] || card.images[0]}
|
|
226
|
+
alt={card.title}
|
|
227
|
+
className="w-full h-full object-cover"
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
{/* 右下小图(反向倾斜,与右上图错开) */}
|
|
231
|
+
<div
|
|
232
|
+
className="absolute bottom-[6px] right-[18px] w-[110px] h-[85px] rounded-[12px] overflow-hidden shadow-lg border-[2px] border-white"
|
|
233
|
+
style={{ transform: "rotate(-3deg)", zIndex: 3 }}
|
|
234
|
+
>
|
|
235
|
+
<ImageWithFallback
|
|
236
|
+
src={card.images[2] || card.images[0]}
|
|
237
|
+
alt={card.title}
|
|
238
|
+
className="w-full h-full object-cover"
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
{/* 距离标签 */}
|
|
242
|
+
<div className="absolute top-[var(--spacing-sm)] left-[var(--spacing-sm)] flex items-center gap-[3px] bg-black/40 backdrop-blur-sm rounded-[8px] px-[8px] py-[4px]" style={{ zIndex: 4 }}>
|
|
243
|
+
<IconFont name="location" size="10px" className="text-white/90" />
|
|
244
|
+
<span className="text-[11px] text-white/90 leading-[14px]">距你{card.distance}</span>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ══════════════════════════════════════════
|
|
251
|
+
推荐卡片组件
|
|
252
|
+
══════════════════════════════════════════ */
|
|
253
|
+
|
|
254
|
+
function RecommendCard({
|
|
255
|
+
card,
|
|
256
|
+
onTap,
|
|
257
|
+
onAiPrompt,
|
|
258
|
+
}: {
|
|
259
|
+
card: POICard;
|
|
260
|
+
onTap: () => void;
|
|
261
|
+
onAiPrompt: (prompt: string) => void;
|
|
262
|
+
}) {
|
|
263
|
+
return (
|
|
264
|
+
<div
|
|
265
|
+
className={cn(
|
|
266
|
+
"flex flex-col shrink-0 w-[280px] cursor-pointer group",
|
|
267
|
+
"bg-card rounded-[var(--radius-card)]",
|
|
268
|
+
"border border-card-border",
|
|
269
|
+
"shadow-[var(--elevation-sm)]",
|
|
270
|
+
"overflow-hidden"
|
|
271
|
+
)}
|
|
272
|
+
onClick={onTap}
|
|
273
|
+
>
|
|
274
|
+
{/* 图片区域 — 根据类型渲染不同布局 */}
|
|
275
|
+
{card.type === "route" && <RouteImageLayout card={card} />}
|
|
276
|
+
{card.type === "single" && <SingleImageLayout card={card} />}
|
|
277
|
+
{card.type === "topic" && <TopicImageLayout card={card} />}
|
|
278
|
+
{card.type === "explore" && <ExploreImageLayout card={card} />}
|
|
279
|
+
|
|
280
|
+
{/* 卡片内容区域 */}
|
|
281
|
+
<div className="flex flex-col p-[var(--spacing-base)]">
|
|
282
|
+
{/* 标题 */}
|
|
283
|
+
<h3 className="text-[15px] font-semibold text-foreground leading-[22px] line-clamp-2 mb-[var(--spacing-xs)]">
|
|
284
|
+
{card.title}
|
|
285
|
+
</h3>
|
|
286
|
+
|
|
287
|
+
{/* 标签行 */}
|
|
288
|
+
<div className="flex flex-wrap gap-[4px] mb-[var(--spacing-sm)]">
|
|
289
|
+
{card.tags.map((tag, i) => (
|
|
290
|
+
<Tag
|
|
291
|
+
key={i}
|
|
292
|
+
label={tag.label}
|
|
293
|
+
size="xs"
|
|
294
|
+
icon={
|
|
295
|
+
tag.icon === "flag" ? (
|
|
296
|
+
<IconFont name="flag" size="10px" className={tag.accent ? "text-destructive" : "text-muted-foreground"} />
|
|
297
|
+
) : tag.icon === "pin" ? (
|
|
298
|
+
<IconFont name="location" size="10px" className="text-accent" />
|
|
299
|
+
) : undefined
|
|
300
|
+
}
|
|
301
|
+
className={cn(
|
|
302
|
+
"border-transparent",
|
|
303
|
+
tag.accent
|
|
304
|
+
? "bg-accent/8 text-foreground/70"
|
|
305
|
+
: "bg-card-muted text-muted-foreground"
|
|
306
|
+
)}
|
|
307
|
+
/>
|
|
308
|
+
))}
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
{/* AI 追问提示 */}
|
|
312
|
+
<button
|
|
313
|
+
onClick={(e) => {
|
|
314
|
+
e.stopPropagation();
|
|
315
|
+
onAiPrompt(card.aiPrompt);
|
|
316
|
+
}}
|
|
317
|
+
className={cn(
|
|
318
|
+
"flex items-center gap-[6px] px-[var(--spacing-sm)] py-[6px]",
|
|
319
|
+
"bg-card-muted rounded-[var(--radius)] w-full",
|
|
320
|
+
"active:bg-muted transition-colors text-left"
|
|
321
|
+
)}
|
|
322
|
+
>
|
|
323
|
+
<IconFont name="star" size="12px" className="text-accent shrink-0" />
|
|
324
|
+
<span className="text-[12px] text-muted-foreground leading-[16px] truncate flex-1">
|
|
325
|
+
{card.aiPrompt}
|
|
326
|
+
</span>
|
|
327
|
+
</button>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* ══════════════════════════════════════════
|
|
334
|
+
AI 输入栏组件
|
|
335
|
+
══════════════════════════════════════════ */
|
|
336
|
+
|
|
337
|
+
function AIInputBar({
|
|
338
|
+
value,
|
|
339
|
+
onChange,
|
|
340
|
+
onSend,
|
|
341
|
+
placeholder,
|
|
342
|
+
}: {
|
|
343
|
+
value: string;
|
|
344
|
+
onChange: (v: string) => void;
|
|
345
|
+
onSend: () => void;
|
|
346
|
+
placeholder: string;
|
|
347
|
+
}) {
|
|
348
|
+
return (
|
|
349
|
+
<div
|
|
350
|
+
className={cn(
|
|
351
|
+
"flex items-center gap-[var(--spacing-xs)] px-[var(--spacing-md)] py-[var(--spacing-xs)]",
|
|
352
|
+
"bg-card backdrop-blur-xl rounded-[var(--radius-button)]",
|
|
353
|
+
"shadow-[var(--elevation-sm)]",
|
|
354
|
+
"border border-card-border"
|
|
355
|
+
)}
|
|
356
|
+
>
|
|
357
|
+
{/* 声纹图标 */}
|
|
358
|
+
<div className="shrink-0">
|
|
359
|
+
<IconFont name="sound" size="18px" className="text-accent" />
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{/* 输入框 */}
|
|
363
|
+
<input
|
|
364
|
+
type="text"
|
|
365
|
+
value={value}
|
|
366
|
+
onChange={(e) => onChange(e.target.value)}
|
|
367
|
+
onKeyDown={(e) => {
|
|
368
|
+
if (e.key === "Enter" && value.trim()) {
|
|
369
|
+
onSend();
|
|
370
|
+
}
|
|
371
|
+
}}
|
|
372
|
+
placeholder={placeholder}
|
|
373
|
+
className={cn(
|
|
374
|
+
"flex-1 bg-transparent border-none outline-none",
|
|
375
|
+
"text-[14px] text-foreground placeholder:text-muted-foreground/70",
|
|
376
|
+
"min-w-0"
|
|
377
|
+
)}
|
|
378
|
+
/>
|
|
379
|
+
|
|
380
|
+
{/* 发送按钮 */}
|
|
381
|
+
{value.trim() && (
|
|
382
|
+
<motion.button
|
|
383
|
+
initial={{ scale: 0, opacity: 0 }}
|
|
384
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
385
|
+
exit={{ scale: 0, opacity: 0 }}
|
|
386
|
+
onClick={onSend}
|
|
387
|
+
className="size-[32px] rounded-full bg-accent flex items-center justify-center shrink-0 active:opacity-80 transition-opacity"
|
|
388
|
+
>
|
|
389
|
+
<IconFont name="send" size="16px" className="text-white" />
|
|
390
|
+
</motion.button>
|
|
391
|
+
)}
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* ══════════════════════════════════════════
|
|
397
|
+
AI 生成结果面板
|
|
398
|
+
══════════════════════════════════════════ */
|
|
399
|
+
|
|
400
|
+
interface AIResult {
|
|
401
|
+
name: string;
|
|
402
|
+
address: string;
|
|
403
|
+
reason: string;
|
|
404
|
+
distance: string;
|
|
405
|
+
rating: string;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const mockAIResults: AIResult[] = [
|
|
409
|
+
{ name: "胡桃里音乐酒馆", address: "国贸商城B1层", reason: "现场驻唱 + 创意菜,适合4人聚餐", distance: "800m", rating: "4.8" },
|
|
410
|
+
{ name: "海底捞(国贸店)", address: "建国门外大街1号", reason: "下班后聚餐首选,不用排队", distance: "1.2km", rating: "4.7" },
|
|
411
|
+
{ name: "探鱼(三里屯店)", address: "三里屯路19号", reason: "烤鱼配啤酒,性价比高", distance: "2.1km", rating: "4.6" },
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
/* ══════════════════════════════════════════
|
|
415
|
+
主页面
|
|
416
|
+
══════════════════════════════════════════ */
|
|
417
|
+
|
|
418
|
+
export function POIRecommendPage() {
|
|
419
|
+
const [inputValue, setInputValue] = useState("");
|
|
420
|
+
const [toastText, setToastText] = useState<string | null>(null);
|
|
421
|
+
const [aiGenerating, setAiGenerating] = useState(false);
|
|
422
|
+
const [aiResults, setAiResults] = useState<AIResult[] | null>(null);
|
|
423
|
+
const [currentPrompt, setCurrentPrompt] = useState("");
|
|
424
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
425
|
+
|
|
426
|
+
const showToast = useCallback((text: string) => {
|
|
427
|
+
setToastText(text);
|
|
428
|
+
}, []);
|
|
429
|
+
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
if (!toastText) return;
|
|
432
|
+
const timer = setTimeout(() => setToastText(null), 2200);
|
|
433
|
+
return () => clearTimeout(timer);
|
|
434
|
+
}, [toastText]);
|
|
435
|
+
|
|
436
|
+
const handleSend = useCallback(() => {
|
|
437
|
+
if (!inputValue.trim()) return;
|
|
438
|
+
setCurrentPrompt(inputValue);
|
|
439
|
+
setInputValue("");
|
|
440
|
+
setAiGenerating(true);
|
|
441
|
+
setAiResults(null);
|
|
442
|
+
|
|
443
|
+
// 模拟 AI 生成
|
|
444
|
+
setTimeout(() => {
|
|
445
|
+
setAiGenerating(false);
|
|
446
|
+
setAiResults(mockAIResults);
|
|
447
|
+
}, 2000);
|
|
448
|
+
}, [inputValue]);
|
|
449
|
+
|
|
450
|
+
const handleAiPrompt = useCallback((prompt: string) => {
|
|
451
|
+
setInputValue(prompt.replace(/^"|"$/g, ""));
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
454
|
+
const handleCloseAI = useCallback(() => {
|
|
455
|
+
setAiResults(null);
|
|
456
|
+
setCurrentPrompt("");
|
|
457
|
+
}, []);
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<div
|
|
461
|
+
className="relative w-full h-[100dvh] overflow-hidden flex flex-col"
|
|
462
|
+
style={{ fontFamily: "var(--font-family-sans)" }}
|
|
463
|
+
>
|
|
464
|
+
{/* ── 渐变背景 ── */}
|
|
465
|
+
<div className="absolute inset-0 bg-gradient-to-b from-[#E8EEF8] via-[#F0F4FA] to-background" />
|
|
466
|
+
{/* 装饰性模糊光斑 */}
|
|
467
|
+
<div className="absolute top-[-60px] right-[-40px] w-[200px] h-[200px] bg-accent/8 rounded-full blur-[80px]" />
|
|
468
|
+
<div className="absolute top-[100px] left-[-60px] w-[180px] h-[180px] bg-[#F0D6A0]/15 rounded-full blur-[70px]" />
|
|
469
|
+
|
|
470
|
+
{/* ── 主内容区 ── */}
|
|
471
|
+
<div className="relative z-10 flex-1 flex flex-col overflow-hidden">
|
|
472
|
+
|
|
473
|
+
{/* ── 顶部标题区 ── */}
|
|
474
|
+
<div className="px-[var(--spacing-lg)] pt-[56px] pb-[var(--spacing-sm)]">
|
|
475
|
+
{/* 标题 */}
|
|
476
|
+
<div className="flex items-start justify-between">
|
|
477
|
+
<div>
|
|
478
|
+
<div className="flex items-center gap-[6px] mb-[2px]">
|
|
479
|
+
<IconFont name="cherry" size="22px" className="text-[#C57B3C]" />
|
|
480
|
+
<h1 className="text-[22px] font-bold text-foreground leading-[30px]">
|
|
481
|
+
秋色正好
|
|
482
|
+
</h1>
|
|
483
|
+
</div>
|
|
484
|
+
<h2 className="text-[22px] font-bold text-foreground leading-[30px]">
|
|
485
|
+
去这些地方兜兜风
|
|
486
|
+
</h2>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
{/* 当前位置 */}
|
|
490
|
+
<div className="flex items-center gap-[4px] mt-[6px] shrink-0">
|
|
491
|
+
<IconFont name="location" size="14px" className="text-accent" />
|
|
492
|
+
<span className="text-[12px] text-muted-foreground leading-[16px]">
|
|
493
|
+
你正在 <span className="text-accent font-medium">SKP(国贸店)</span> 附近
|
|
494
|
+
</span>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
{/* ── 卡片横向滚动区 ── */}
|
|
500
|
+
<div className="flex-1 min-h-0 relative">
|
|
501
|
+
<div
|
|
502
|
+
ref={scrollRef}
|
|
503
|
+
className="flex gap-[var(--spacing-base)] overflow-x-auto px-[var(--spacing-lg)] pb-[var(--spacing-base)] pt-[var(--spacing-xs)] snap-x snap-mandatory scrollbar-hide"
|
|
504
|
+
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
|
505
|
+
>
|
|
506
|
+
{poiCards.map((card) => (
|
|
507
|
+
<div key={card.id} className="snap-start">
|
|
508
|
+
<RecommendCard
|
|
509
|
+
card={card}
|
|
510
|
+
onTap={() => showToast(`查看「${card.title.slice(0, 8)}...」详情`)}
|
|
511
|
+
onAiPrompt={handleAiPrompt}
|
|
512
|
+
/>
|
|
513
|
+
</div>
|
|
514
|
+
))}
|
|
515
|
+
{/* 右侧留白 */}
|
|
516
|
+
<div className="shrink-0 w-[var(--spacing-lg)]" />
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
{/* 右侧播放按钮(自动播放指示) */}
|
|
520
|
+
<div className="absolute right-[var(--spacing-base)] top-[var(--spacing-xs)]">
|
|
521
|
+
<button
|
|
522
|
+
className="size-[36px] rounded-full bg-accent flex items-center justify-center shadow-md active:opacity-80 transition-opacity"
|
|
523
|
+
onClick={() => showToast("自动播放推荐中...")}
|
|
524
|
+
>
|
|
525
|
+
<IconFont name="play-circle" size="20px" className="text-white" />
|
|
526
|
+
</button>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
|
|
530
|
+
{/* ── AI 结果面板 ── */}
|
|
531
|
+
<AnimatePresence>
|
|
532
|
+
{(aiGenerating || aiResults) && (
|
|
533
|
+
<motion.div
|
|
534
|
+
initial={{ opacity: 0, y: 40 }}
|
|
535
|
+
animate={{ opacity: 1, y: 0 }}
|
|
536
|
+
exit={{ opacity: 0, y: 40 }}
|
|
537
|
+
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
|
538
|
+
className="absolute inset-x-0 bottom-[80px] z-30 mx-[var(--spacing-base)]"
|
|
539
|
+
>
|
|
540
|
+
<div
|
|
541
|
+
className={cn(
|
|
542
|
+
"rounded-[var(--radius-card)] overflow-hidden",
|
|
543
|
+
"bg-card backdrop-blur-xl",
|
|
544
|
+
"shadow-[var(--elevation-sm)]",
|
|
545
|
+
"border border-card-border"
|
|
546
|
+
)}
|
|
547
|
+
>
|
|
548
|
+
{/* 头部 */}
|
|
549
|
+
<div className="flex items-center justify-between px-[var(--spacing-base)] pt-[var(--spacing-base)] pb-[var(--spacing-xs)]">
|
|
550
|
+
<div className="flex items-center gap-[6px]">
|
|
551
|
+
<IconFont name="star" size="14px" className="text-accent" />
|
|
552
|
+
<span className="text-[14px] font-medium text-foreground leading-[20px]">
|
|
553
|
+
AI 推荐
|
|
554
|
+
</span>
|
|
555
|
+
{currentPrompt && (
|
|
556
|
+
<span className="text-[12px] text-muted-foreground ml-[4px] truncate max-w-[180px]">
|
|
557
|
+
「{currentPrompt}」
|
|
558
|
+
</span>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
561
|
+
<button
|
|
562
|
+
onClick={handleCloseAI}
|
|
563
|
+
className="text-muted-foreground active:opacity-60 text-[12px]"
|
|
564
|
+
>
|
|
565
|
+
关闭
|
|
566
|
+
</button>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
{/* 内容 */}
|
|
570
|
+
<div className="px-[var(--spacing-base)] pb-[var(--spacing-base)] max-h-[280px] overflow-y-auto">
|
|
571
|
+
{aiGenerating ? (
|
|
572
|
+
<div className="flex flex-col items-center py-[var(--spacing-xl)]">
|
|
573
|
+
<div className="flex items-center gap-[8px] mb-[var(--spacing-sm)]">
|
|
574
|
+
<motion.div
|
|
575
|
+
className="size-[8px] rounded-full bg-accent"
|
|
576
|
+
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
|
|
577
|
+
transition={{ repeat: Infinity, duration: 1, delay: 0 }}
|
|
578
|
+
/>
|
|
579
|
+
<motion.div
|
|
580
|
+
className="size-[8px] rounded-full bg-accent"
|
|
581
|
+
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
|
|
582
|
+
transition={{ repeat: Infinity, duration: 1, delay: 0.2 }}
|
|
583
|
+
/>
|
|
584
|
+
<motion.div
|
|
585
|
+
className="size-[8px] rounded-full bg-accent"
|
|
586
|
+
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
|
|
587
|
+
transition={{ repeat: Infinity, duration: 1, delay: 0.4 }}
|
|
588
|
+
/>
|
|
589
|
+
</div>
|
|
590
|
+
<p className="text-[13px] text-muted-foreground">正在为你生成推荐...</p>
|
|
591
|
+
</div>
|
|
592
|
+
) : aiResults ? (
|
|
593
|
+
<div className="flex flex-col gap-[var(--spacing-xs)]">
|
|
594
|
+
{aiResults.map((result, index) => (
|
|
595
|
+
<motion.button
|
|
596
|
+
key={result.name}
|
|
597
|
+
initial={{ opacity: 0, x: -12 }}
|
|
598
|
+
animate={{ opacity: 1, x: 0 }}
|
|
599
|
+
transition={{ delay: index * 0.1 }}
|
|
600
|
+
onClick={() => showToast(`正在导航至${result.name}...`)}
|
|
601
|
+
className={cn(
|
|
602
|
+
"flex items-center gap-[var(--spacing-sm)] p-[var(--spacing-sm)] text-left",
|
|
603
|
+
"rounded-[16px] bg-card-muted active:bg-muted transition-colors"
|
|
604
|
+
)}
|
|
605
|
+
>
|
|
606
|
+
{/* 序号 */}
|
|
607
|
+
<div
|
|
608
|
+
className={cn(
|
|
609
|
+
"size-[28px] rounded-full flex items-center justify-center shrink-0 text-[12px] font-bold",
|
|
610
|
+
index === 0
|
|
611
|
+
? "bg-accent text-white"
|
|
612
|
+
: "bg-muted text-muted-foreground"
|
|
613
|
+
)}
|
|
614
|
+
>
|
|
615
|
+
{index + 1}
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
{/* 信息 */}
|
|
619
|
+
<div className="flex-1 min-w-0">
|
|
620
|
+
<div className="flex items-center gap-[6px]">
|
|
621
|
+
<span className="text-[14px] font-medium text-foreground leading-[20px] truncate">
|
|
622
|
+
{result.name}
|
|
623
|
+
</span>
|
|
624
|
+
<span className="text-[12px] text-accent font-medium shrink-0">
|
|
625
|
+
{result.rating}分
|
|
626
|
+
</span>
|
|
627
|
+
</div>
|
|
628
|
+
<p className="text-[12px] text-muted-foreground leading-[16px] truncate mt-[1px]">
|
|
629
|
+
{result.reason}
|
|
630
|
+
</p>
|
|
631
|
+
</div>
|
|
632
|
+
|
|
633
|
+
{/* 距离 + 导航 */}
|
|
634
|
+
<div className="flex flex-col items-end shrink-0 gap-[2px]">
|
|
635
|
+
<span className="text-[12px] text-accent font-medium">{result.distance}</span>
|
|
636
|
+
<Btn size="xsmall" variant="primary" label="去这里" icon={null} />
|
|
637
|
+
</div>
|
|
638
|
+
</motion.button>
|
|
639
|
+
))}
|
|
640
|
+
</div>
|
|
641
|
+
) : null}
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
</motion.div>
|
|
645
|
+
)}
|
|
646
|
+
</AnimatePresence>
|
|
647
|
+
|
|
648
|
+
{/* ── 底部 AI 输入栏 ── */}
|
|
649
|
+
<div className="relative z-40 px-[var(--spacing-lg)] pb-[var(--spacing-lg)] pt-[var(--spacing-xs)]">
|
|
650
|
+
<AIInputBar
|
|
651
|
+
value={inputValue}
|
|
652
|
+
onChange={setInputValue}
|
|
653
|
+
onSend={handleSend}
|
|
654
|
+
placeholder="适合下班后和同事聚餐的地方,4个人"
|
|
655
|
+
/>
|
|
656
|
+
</div>
|
|
657
|
+
</div>
|
|
658
|
+
|
|
659
|
+
{/* ── Toast ── */}
|
|
660
|
+
<div className="fixed top-[120px] left-0 right-0 z-[200] flex justify-center pointer-events-none">
|
|
661
|
+
<AnimatePresence>
|
|
662
|
+
{toastText && (
|
|
663
|
+
<div className="pointer-events-auto">
|
|
664
|
+
<Toast
|
|
665
|
+
lines={[toastText]}
|
|
666
|
+
showIcon={true}
|
|
667
|
+
showClose={true}
|
|
668
|
+
onClose={() => setToastText(null)}
|
|
669
|
+
/>
|
|
670
|
+
</div>
|
|
671
|
+
)}
|
|
672
|
+
</AnimatePresence>
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
);
|
|
676
|
+
}
|